mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf55d13f67 | ||
|
|
de83a884c2 | ||
|
|
dd2a81b6f2 | ||
|
|
176b9d03ac | ||
|
|
e4f44c10e8 | ||
|
|
11dd554204 | ||
|
|
9f21ff13ae | ||
|
|
1a1d8748cb | ||
|
|
38973b4698 | ||
|
|
1bd261bee0 | ||
|
|
df6979a59b | ||
|
|
2427f0bc5f | ||
|
|
3356c631bb | ||
|
|
3d34bfafd5 | ||
|
|
3c3e6934d7 | ||
|
|
84e1e4820c | ||
|
|
012368c52b | ||
|
|
82c42f99fe | ||
|
|
5da0855a52 | ||
|
|
ed9fdf5d60 | ||
|
|
d84b9385ad | ||
|
|
c4318d4923 | ||
|
|
5b54ab28cb | ||
|
|
503ec126a5 | ||
|
|
3d6e3901d0 | ||
|
|
4df89a793e | ||
|
|
e42e08e35d | ||
|
|
7ed6f7ee93 | ||
|
|
9b0d4b3149 | ||
|
|
f0f3d419f8 | ||
|
|
26e2036388 | ||
|
|
22f5d028a2 |
79
README.md
79
README.md
@@ -28,13 +28,13 @@
|
||||
|
||||
---
|
||||
|
||||
https://github.com/user-attachments/assets/0f557d87-fd5e-422b-ab7e-dbdd4cab156c
|
||||
https://github.com/user-attachments/assets/90fffb9a-dae2-4d19-aca2-5d47600f0a01
|
||||
|
||||
https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
- **Freeform Inpainting Area:** Draw any custom (non-rectangular) area directly inside the image for inpainting. The tool generates content that is coherent with the rest of the image, without requiring a brush.
|
||||
- **Freeform Inpainting Area:** Draw any custom (like a polygonal lasso tool) area directly inside the image for inpainting. The tool generates content that is coherent with the rest of the image, without requiring a brush.
|
||||
- **Persistent & Stateful:** Your work is automatically saved to the browser's IndexedDB, preserving your full canvas
|
||||
state (layers, positions, etc.) even after a page reload.
|
||||
- **Multi-Layer Editing:** Add, arrange, and manage multiple image layers with z-ordering.
|
||||
@@ -71,16 +71,67 @@ https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
||||
3. Start up ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Polygonal Lasso Inpainting Workflow
|
||||
|
||||
LayerForge's newest feature allows you to draw custom polygonal selection areas and run inpainting directly within ComfyUI. This brings Photoshop-like lasso tool functionality to your AI workflows.
|
||||
|
||||
### Setup Requirements
|
||||
|
||||
1. **Enable Auto-Refresh:** In LayerForge's settings, enable `auto_refresh_after_generation`. Without this setting, the new generation output won't update automatically in the canvas.
|
||||
|
||||
2. **Configure Auto-Apply (Optional):** If you want the mask to be automatically applied after drawing the shape, enable the `auto-apply shape mask` option in the Custom Output Area menu (appears on the left when a custom shape is active).
|
||||
|
||||
### How to Use Polygonal Selection
|
||||
|
||||
1. **Start Drawing:** Hold `Shift + S` and left-click to place the first point of your polygonal selection.
|
||||
|
||||
2. **Add Points:** Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.
|
||||
|
||||
3. **Close Selection:** Click back on the first point (or close to it) to complete and close the polygonal selection.
|
||||
|
||||
4. **Run Inpainting:** Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image.
|
||||
|
||||
### Advanced Shape Mask Options
|
||||
|
||||
When using custom shapes, LayerForge provides several options to fine-tune the mask quality:
|
||||
|
||||
- **Mask Expansion/Contraction:** Adjust the mask boundary by -300 to +300 pixels to ensure better blending
|
||||
- **Edge Feathering:** Apply 0-300px feathering to create smooth transitions and reduce visible seams
|
||||
- **Output Area Extension:** Extend the output area in all directions for more context during generation
|
||||
- **Manual Blend Menu:** Right-click to access manual color adjustment tools for perfect edge blending
|
||||
|
||||
### Tips for Best Results
|
||||
|
||||
* Use **feathering (10–50px)** depending on the **size of the image** to create smooth transitions between the inpainted area and existing content. Larger images generally benefit from more feathering.
|
||||
* Experiment with **mask expansion** (e.g., 10–20px) if you notice hard edges or visible seams.
|
||||
* Use **Output Area Extension** based on image size:
|
||||
|
||||
* **Extend the output area in all directions** to give the model more **context during generation**, especially for larger or more complex images.
|
||||
* If **visible seams** still appear in the inpainting results:
|
||||
|
||||
* Use the **Manual Blend Menu** (right-click on the mask area) to access **color and edge adjustment tools** for precise fine-tuning and seamless integration.
|
||||
* **Image placement behavior:**
|
||||
|
||||
* The generated or pasted image is automatically inserted into the area defined by the **blue shape** you draw.
|
||||
* The model uses the area within the **dashed white preview outline** as the **full context** during generation.
|
||||
* Make sure the dashed region covers enough surrounding content to preserve lighting, texture, and scene coherence.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Workflow Example
|
||||
|
||||
For a quick test of **LayerForge**, you can try the example workflow provided below. It demonstrates a basic compositing setup using the node.
|
||||
|
||||
**🔗 Download Example Workflow**
|
||||
|
||||

|
||||
### 🔹 Simple Test Workflow
|
||||
This workflow allows **quick testing** of node behavior and output structures **without requiring additional models or complex dependencies**. Useful for inspecting how basic outputs are generated and connected.
|
||||

|
||||
|
||||
|
||||

|
||||
### 🔹 Flux Inpainting Workflow
|
||||
This example shows a typical **inpainting setup using the Flux model**. It demonstrates how to integrate model-based fill with contextual generation for seamless content restoration.
|
||||

|
||||
|
||||
|
||||
|
||||
@@ -89,7 +140,6 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🎮 Controls & Shortcuts
|
||||
|
||||
### Canvas Control
|
||||
@@ -100,7 +150,7 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
||||
| `Mouse Wheel` | Zoom view in/out |
|
||||
| `Shift + Click (background)` | Start resizing canvas area |
|
||||
| `Shift + Ctrl + Click` | Start moving entire canvas |
|
||||
| `Shift + S + Left Click` | Draw custom shape for output area |
|
||||
| `Shift + S + Left Click` | Draw custom polygonal shape for output area |
|
||||
| `Single Click (background)` | Deselect all layers |
|
||||
| `Esc` | Close fullscreen editor mode |
|
||||
| `Double Click (background)` | Deselect all layers |
|
||||
@@ -151,6 +201,14 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
||||
| **Clear Mask** | Remove the entire mask |
|
||||
| **Exit Mode** | Click the "Draw Mask" button again |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Model Compatibility
|
||||
|
||||
LayerForge is designed to work with **any ComfyUI-compatible model**. The node outputs standard image and mask data that can be used with any model or workflow. LayerForge automatically inserts the generated image into the exact shape and position you draw with the blue polygon tool — but only if the generated image is saved properly, for example via a Save Image node.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Optional: Matting Model (for image cutout)
|
||||
|
||||
The "Matting" feature allows you to automatically generate a cutout (alpha mask) for a selected layer. This is an
|
||||
@@ -165,7 +223,8 @@ optional feature and requires a model.
|
||||
|
||||
---
|
||||
|
||||
## 🐞 Known Issue:
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### `node_id` not auto-filled → black output
|
||||
|
||||
In some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node.
|
||||
@@ -189,5 +248,9 @@ This project is licensed under the MIT License. Feel free to use, modify, and di
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork
|
||||
significantly enhances the editing capabilities for practical compositing workflows inside ComfyUI.
|
||||
|
||||
Special thanks to the ComfyUI community for feedback, bug reports, and feature suggestions that help make LayerForge better.
|
||||
|
||||
@@ -4,16 +4,16 @@ import os
|
||||
# Add the custom node's directory to the Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from .canvas_node import CanvasNode
|
||||
from .canvas_node import LayerForgeNode
|
||||
|
||||
CanvasNode.setup_routes()
|
||||
LayerForgeNode.setup_routes()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"CanvasNode": CanvasNode
|
||||
"LayerForgeNode": LayerForgeNode
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"CanvasNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
|
||||
"LayerForgeNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
@@ -90,7 +90,7 @@ class BiRefNet(torch.nn.Module):
|
||||
return [output]
|
||||
|
||||
|
||||
class CanvasNode:
|
||||
class LayerForgeNode:
|
||||
_canvas_data_storage = {}
|
||||
_storage_lock = threading.Lock()
|
||||
|
||||
@@ -912,12 +912,3 @@ def convert_tensor_to_base64(tensor, alpha_mask=None, original_alpha=None):
|
||||
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
|
||||
raise
|
||||
|
||||
CanvasNode.setup_routes()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"CanvasNode": CanvasNode
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"CanvasNode": "LayerForge"
|
||||
}
|
||||
|
||||
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.jpg
Normal file
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "d26732fd-91ea-4503-8d0d-383544823cec",
|
||||
"revision": 0,
|
||||
"last_node_id": 49,
|
||||
"last_link_id": 112,
|
||||
"last_node_id": 52,
|
||||
"last_link_id": 114,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
@@ -18,7 +18,7 @@
|
||||
"flags": {
|
||||
"collapsed": true
|
||||
},
|
||||
"order": 6,
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -62,7 +62,7 @@
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -103,7 +103,7 @@
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -260,7 +260,7 @@
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 12,
|
||||
"order": 14,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -304,7 +304,7 @@
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"order": 12,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -344,7 +344,7 @@
|
||||
138
|
||||
],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"order": 11,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -365,12 +365,12 @@
|
||||
{
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 106
|
||||
"link": 113
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 107
|
||||
"link": 114
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
@@ -421,7 +421,7 @@
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
"order": 13,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -462,7 +462,7 @@
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
858769863184862,
|
||||
1006953529460557,
|
||||
"randomize",
|
||||
20,
|
||||
1,
|
||||
@@ -526,7 +526,7 @@
|
||||
893.8499755859375
|
||||
],
|
||||
"flags": {},
|
||||
"order": 13,
|
||||
"order": 15,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -550,15 +550,15 @@
|
||||
"id": 23,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
-835.4583129882812,
|
||||
878.8148193359375
|
||||
-905.195556640625,
|
||||
924.5140991210938
|
||||
],
|
||||
"size": [
|
||||
311.0955810546875,
|
||||
108.43277740478516
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -591,48 +591,94 @@
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"type": "CanvasNode",
|
||||
"id": 51,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
-514.2837524414062,
|
||||
543.1272583007812
|
||||
-916.8970947265625,
|
||||
476.72564697265625
|
||||
],
|
||||
"size": [
|
||||
1862.893798828125,
|
||||
1237.79638671875
|
||||
350.92510986328125,
|
||||
250.50831604003906
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"How to Use Polygonal Selection\n- Start Drawing: Hold Shift + S and left-click to place the first point of your polygonal selection.\n\n- Add Points: Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.\n\n- Close Selection: Click back on the first point (or close to it) to complete and close the polygonal selection.\n\n- Run Inpainting: Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image."
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
-911.10205078125,
|
||||
769.1378173828125
|
||||
],
|
||||
"size": [
|
||||
350.28143310546875,
|
||||
99.23915100097656
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Add a description at the bottom to tell the model what to generate."
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"type": "LayerForgeNode",
|
||||
"pos": [
|
||||
-553.9073486328125,
|
||||
478.2644348144531
|
||||
],
|
||||
"size": [
|
||||
1879.927490234375,
|
||||
1259.4072265625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
106
|
||||
113
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
107
|
||||
114
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "Comfyui-Ycanvas",
|
||||
"ver": "3941104bd59dd79c19d612da1b11c05d87c2ed1c",
|
||||
"Node name for S&R": "CanvasNode",
|
||||
"cnr_id": "layerforge",
|
||||
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
|
||||
"Node name for S&R": "LayerForgeNode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
963,
|
||||
"48",
|
||||
18,
|
||||
"50",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -734,22 +780,6 @@
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
106,
|
||||
48,
|
||||
0,
|
||||
38,
|
||||
3,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
107,
|
||||
48,
|
||||
1,
|
||||
38,
|
||||
4,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
110,
|
||||
38,
|
||||
@@ -773,6 +803,22 @@
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
113,
|
||||
50,
|
||||
0,
|
||||
38,
|
||||
3,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
114,
|
||||
50,
|
||||
1,
|
||||
38,
|
||||
4,
|
||||
"MASK"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
@@ -781,8 +827,8 @@
|
||||
"ds": {
|
||||
"scale": 0.6588450000000008,
|
||||
"offset": [
|
||||
1318.77716124466,
|
||||
-32.39290875553955
|
||||
1117.7398801488407,
|
||||
-110.40634975151642
|
||||
]
|
||||
},
|
||||
"ue_links": [],
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
example_workflows/LayerForge_test_simple_workflow.jpg
Normal file
BIN
example_workflows/LayerForge_test_simple_workflow.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -1,19 +1,137 @@
|
||||
{
|
||||
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
|
||||
"revision": 0,
|
||||
"last_node_id": 707,
|
||||
"last_link_id": 1499,
|
||||
"last_node_id": 710,
|
||||
"last_link_id": 1505,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 708,
|
||||
"type": "LayerForgeNode",
|
||||
"pos": [
|
||||
-3077.55615234375,
|
||||
-3358.0537109375
|
||||
],
|
||||
"size": [
|
||||
1150,
|
||||
1000
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1500
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
1501
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "layerforge",
|
||||
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
|
||||
"widget_ue_connectable": {},
|
||||
"Node name for S&R": "LayerForgeNode"
|
||||
},
|
||||
"widgets_values": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
11,
|
||||
"708",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 709,
|
||||
"type": "Reroute",
|
||||
"pos": [
|
||||
-1920.4510498046875,
|
||||
-3559.688232421875
|
||||
],
|
||||
"size": [
|
||||
75,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "*",
|
||||
"link": 1500
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1502,
|
||||
1503
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"showOutputText": false,
|
||||
"horizontal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 710,
|
||||
"type": "Reroute",
|
||||
"pos": [
|
||||
-1917.6273193359375,
|
||||
-3524.312744140625
|
||||
],
|
||||
"size": [
|
||||
75,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "*",
|
||||
"link": 1501
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
1504,
|
||||
1505
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"showOutputText": false,
|
||||
"horizontal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 369,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1699.1021728515625,
|
||||
-3355.60498046875
|
||||
-1914.3177490234375,
|
||||
-2807.92724609375
|
||||
],
|
||||
"size": [
|
||||
660.91162109375,
|
||||
400.2092590332031
|
||||
710,
|
||||
450
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
@@ -38,21 +156,21 @@
|
||||
"id": 606,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1911.126708984375,
|
||||
-2916.072998046875
|
||||
-1913.4202880859375,
|
||||
-3428.773193359375
|
||||
],
|
||||
"size": [
|
||||
551.7399291992188,
|
||||
546.8018798828125
|
||||
700,
|
||||
510
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1495
|
||||
"link": 1503
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
@@ -64,92 +182,30 @@
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 603,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1344.1650390625,
|
||||
-2915.117919921875
|
||||
],
|
||||
"size": [
|
||||
601.4136962890625,
|
||||
527.1531372070312
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1236
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 680,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
-1025.9984130859375,
|
||||
-3357.975341796875
|
||||
],
|
||||
"size": [
|
||||
278.8309020996094,
|
||||
395.84002685546875
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1465
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "SaveImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 442,
|
||||
"type": "JoinImageWithAlpha",
|
||||
"pos": [
|
||||
-1902.5858154296875,
|
||||
-3187.159423828125
|
||||
-1190.1787109375,
|
||||
-3237.75732421875
|
||||
],
|
||||
"size": [
|
||||
176.86483764648438,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1494
|
||||
"link": 1502
|
||||
},
|
||||
{
|
||||
"name": "alpha",
|
||||
"type": "MASK",
|
||||
"link": 1497
|
||||
"link": 1505
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
@@ -170,25 +226,87 @@
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 603,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1188.5968017578125,
|
||||
-3143.6875
|
||||
],
|
||||
"size": [
|
||||
640,
|
||||
510
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1236
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 680,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
-536.2315673828125,
|
||||
-3135.49755859375
|
||||
],
|
||||
"size": [
|
||||
279.97137451171875,
|
||||
282
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1465
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "SaveImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 706,
|
||||
"type": "MaskToImage",
|
||||
"pos": [
|
||||
-1901.433349609375,
|
||||
-3332.2021484375
|
||||
-1911.38525390625,
|
||||
-2875.74658203125
|
||||
],
|
||||
"size": [
|
||||
184.62362670898438,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 1498
|
||||
"link": 1504
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
@@ -203,57 +321,10 @@
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"widget_ue_connectable": {},
|
||||
"Node name for S&R": "MaskToImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 697,
|
||||
"type": "CanvasNode",
|
||||
"pos": [
|
||||
-2968.572998046875,
|
||||
-3347.89306640625
|
||||
],
|
||||
"size": [
|
||||
1044.9053955078125,
|
||||
980.680908203125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1494,
|
||||
1495
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
1497,
|
||||
1498
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "Comfyui-Ycanvas",
|
||||
"ver": "f6a491e83bab9481a2cac3367541a3b7803df9ab",
|
||||
"Node name for S&R": "CanvasNode",
|
||||
"Node name for S&R": "MaskToImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
true,
|
||||
false,
|
||||
"697",
|
||||
15,
|
||||
"697",
|
||||
""
|
||||
]
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
@@ -273,38 +344,6 @@
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1494,
|
||||
697,
|
||||
0,
|
||||
442,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1495,
|
||||
697,
|
||||
0,
|
||||
606,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1497,
|
||||
697,
|
||||
1,
|
||||
442,
|
||||
1,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
1498,
|
||||
697,
|
||||
1,
|
||||
706,
|
||||
0,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
1499,
|
||||
706,
|
||||
@@ -312,16 +351,64 @@
|
||||
369,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1500,
|
||||
708,
|
||||
0,
|
||||
709,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
1501,
|
||||
708,
|
||||
1,
|
||||
710,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
1502,
|
||||
709,
|
||||
0,
|
||||
442,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1503,
|
||||
709,
|
||||
0,
|
||||
606,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1504,
|
||||
710,
|
||||
0,
|
||||
706,
|
||||
0,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
1505,
|
||||
710,
|
||||
0,
|
||||
442,
|
||||
1,
|
||||
"MASK"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9646149645000008,
|
||||
"scale": 0.7972024500000005,
|
||||
"offset": [
|
||||
3002.5649125522764,
|
||||
3543.443319064718
|
||||
3208.3419155969927,
|
||||
3617.011371212156
|
||||
]
|
||||
},
|
||||
"ue_links": [],
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 854 KiB |
@@ -123,10 +123,14 @@ export class BatchPreviewManager {
|
||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||
if (this.maskWasVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.remove('primary');
|
||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
|
||||
const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
|
||||
if (toggleSwitch) {
|
||||
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
toggleSwitch.classList.remove('primary');
|
||||
const iconContainer = toggleSwitch.querySelector('.switch-icon');
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '0.5';
|
||||
}
|
||||
@@ -165,10 +169,14 @@ export class BatchPreviewManager {
|
||||
this.canvas.render();
|
||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.add('primary');
|
||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
|
||||
const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
|
||||
if (toggleSwitch) {
|
||||
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
toggleSwitch.classList.add('primary');
|
||||
const iconContainer = toggleSwitch.querySelector('.switch-icon');
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '1';
|
||||
}
|
||||
|
||||
@@ -61,6 +61,15 @@ export class Canvas {
|
||||
});
|
||||
this.offscreenCanvas = offscreenCanvas;
|
||||
this.offscreenCtx = offscreenCtx;
|
||||
// Create overlay canvas for brush cursor and other lightweight overlays
|
||||
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
|
||||
alpha: true,
|
||||
willReadFrequently: false
|
||||
});
|
||||
if (!overlayCtx)
|
||||
throw new Error("Could not create overlay canvas context");
|
||||
this.overlayCanvas = overlayCanvas;
|
||||
this.overlayCtx = overlayCtx;
|
||||
this.canvasContainer = null;
|
||||
this.dataInitialized = false;
|
||||
this.pendingDataCheck = null;
|
||||
|
||||
@@ -238,7 +238,10 @@ export class CanvasIO {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
|
||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. ` +
|
||||
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
|
||||
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
|
||||
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`);
|
||||
}
|
||||
}
|
||||
async addInputToCanvas(inputImage, inputMask) {
|
||||
|
||||
@@ -3,16 +3,33 @@ import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('CanvasInteractions');
|
||||
export class CanvasInteractions {
|
||||
constructor(canvas) {
|
||||
// Bound event handlers to enable proper removeEventListener and avoid leaks
|
||||
this.onMouseDown = (e) => this.handleMouseDown(e);
|
||||
this.onMouseMove = (e) => this.handleMouseMove(e);
|
||||
this.onMouseUp = (e) => this.handleMouseUp(e);
|
||||
this.onMouseEnter = (e) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); };
|
||||
this.onMouseLeave = (e) => { this.canvas.isMouseOver = false; this.handleMouseLeave(e); };
|
||||
this.onWheel = (e) => this.handleWheel(e);
|
||||
this.onKeyDown = (e) => this.handleKeyDown(e);
|
||||
this.onKeyUp = (e) => this.handleKeyUp(e);
|
||||
this.onDragOver = (e) => this.handleDragOver(e);
|
||||
this.onDragEnter = (e) => this.handleDragEnter(e);
|
||||
this.onDragLeave = (e) => this.handleDragLeave(e);
|
||||
this.onDrop = (e) => { this.handleDrop(e); };
|
||||
this.onContextMenu = (e) => this.handleContextMenu(e);
|
||||
this.onBlur = () => this.handleBlur();
|
||||
this.onPaste = (e) => this.handlePasteEvent(e);
|
||||
this.canvas = canvas;
|
||||
this.interaction = {
|
||||
mode: 'none',
|
||||
panStart: { x: 0, y: 0 },
|
||||
dragStart: { x: 0, y: 0 },
|
||||
transformOrigin: {},
|
||||
transformOrigin: null,
|
||||
resizeHandle: null,
|
||||
resizeAnchor: { x: 0, y: 0 },
|
||||
canvasResizeStart: { x: 0, y: 0 },
|
||||
isCtrlPressed: false,
|
||||
isMetaPressed: false,
|
||||
isAltPressed: false,
|
||||
isShiftPressed: false,
|
||||
isSPressed: false,
|
||||
@@ -32,18 +49,29 @@ export class CanvasInteractions {
|
||||
view: this.canvas.getMouseViewCoordinates(e)
|
||||
};
|
||||
}
|
||||
getModifierState(e) {
|
||||
return {
|
||||
ctrl: this.interaction.isCtrlPressed || e?.ctrlKey || false,
|
||||
shift: this.interaction.isShiftPressed || e?.shiftKey || false,
|
||||
alt: this.interaction.isAltPressed || e?.altKey || false,
|
||||
meta: this.interaction.isMetaPressed || e?.metaKey || false,
|
||||
};
|
||||
}
|
||||
preventEventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
performZoomOperation(worldCoords, zoomFactor) {
|
||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
||||
this.canvas.viewport.zoom = newZoom;
|
||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
|
||||
// Update stroke overlay if mask tool is drawing during zoom
|
||||
if (this.canvas.maskTool.isDrawing) {
|
||||
this.canvas.maskTool.handleViewportChange();
|
||||
}
|
||||
this.canvas.onViewportChange?.();
|
||||
}
|
||||
renderAndSave(shouldSave = false) {
|
||||
@@ -64,29 +92,39 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
setupEventListeners() {
|
||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
|
||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown);
|
||||
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
|
||||
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp);
|
||||
this.canvas.canvas.addEventListener('wheel', this.onWheel, { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.onKeyDown);
|
||||
this.canvas.canvas.addEventListener('keyup', this.onKeyUp);
|
||||
// Add a blur event listener to the window to reset key states
|
||||
window.addEventListener('blur', this.handleBlur.bind(this));
|
||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
||||
this.canvas.isMouseOver = true;
|
||||
this.handleMouseEnter(e);
|
||||
});
|
||||
this.canvas.canvas.addEventListener('mouseleave', (e) => {
|
||||
this.canvas.isMouseOver = false;
|
||||
this.handleMouseLeave(e);
|
||||
});
|
||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
||||
window.addEventListener('blur', this.onBlur);
|
||||
document.addEventListener('paste', this.onPaste);
|
||||
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
|
||||
this.canvas.canvas.addEventListener('dragover', this.onDragOver);
|
||||
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter);
|
||||
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave);
|
||||
this.canvas.canvas.addEventListener('drop', this.onDrop);
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu);
|
||||
}
|
||||
teardownEventListeners() {
|
||||
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove);
|
||||
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp);
|
||||
this.canvas.canvas.removeEventListener('wheel', this.onWheel);
|
||||
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown);
|
||||
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp);
|
||||
window.removeEventListener('blur', this.onBlur);
|
||||
document.removeEventListener('paste', this.onPaste);
|
||||
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter);
|
||||
this.canvas.canvas.removeEventListener('mouseleave', this.onMouseLeave);
|
||||
this.canvas.canvas.removeEventListener('dragover', this.onDragOver);
|
||||
this.canvas.canvas.removeEventListener('dragenter', this.onDragEnter);
|
||||
this.canvas.canvas.removeEventListener('dragleave', this.onDragLeave);
|
||||
this.canvas.canvas.removeEventListener('drop', this.onDrop);
|
||||
this.canvas.canvas.removeEventListener('contextmenu', this.onContextMenu);
|
||||
}
|
||||
/**
|
||||
* Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów
|
||||
@@ -124,9 +162,10 @@ export class CanvasInteractions {
|
||||
handleMouseDown(e) {
|
||||
this.canvas.canvas.focus();
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
const mods = this.getModifierState(e);
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
||||
this.canvas.render();
|
||||
// Don't render here - mask tool will handle its own drawing
|
||||
return;
|
||||
}
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
@@ -135,11 +174,11 @@ export class CanvasInteractions {
|
||||
}
|
||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||
if (e.shiftKey && e.ctrlKey) {
|
||||
if (mods.shift && mods.ctrl) {
|
||||
this.startCanvasMove(coords.world);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
if (mods.shift) {
|
||||
// Clear custom shape when starting canvas resize
|
||||
if (this.canvas.outputAreaShape) {
|
||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||
@@ -163,7 +202,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) { // Środkowy przycisk
|
||||
if (e.button === 1) { // Środkowy przycisk
|
||||
this.startPanning(e);
|
||||
return;
|
||||
}
|
||||
@@ -179,7 +218,7 @@ export class CanvasInteractions {
|
||||
return;
|
||||
}
|
||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||
this.startPanningOrClearSelection(e);
|
||||
this.startPanning(e, true); // clearSelection = true
|
||||
}
|
||||
handleMouseMove(e) {
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
@@ -199,7 +238,7 @@ export class CanvasInteractions {
|
||||
switch (this.interaction.mode) {
|
||||
case 'drawingMask':
|
||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
||||
this.canvas.render();
|
||||
// Don't render during mask drawing - it's handled by mask tool internally
|
||||
break;
|
||||
case 'panning':
|
||||
this.panViewport(e);
|
||||
@@ -221,6 +260,10 @@ export class CanvasInteractions {
|
||||
break;
|
||||
default:
|
||||
this.updateCursor(coords.world);
|
||||
// Update brush cursor on overlay if mask tool is active
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.canvasRenderer.drawMaskBrushCursor(coords.world);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
||||
@@ -232,6 +275,7 @@ export class CanvasInteractions {
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||
// Render only once after drawing is complete
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
@@ -245,6 +289,14 @@ export class CanvasInteractions {
|
||||
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
this.logDragCompletion(coords);
|
||||
}
|
||||
// Handle end of crop bounds transformation before resetting interaction state
|
||||
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
|
||||
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
|
||||
}
|
||||
// Handle end of scale transformation (normal transform mode) before resetting interaction state
|
||||
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
|
||||
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
|
||||
}
|
||||
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
||||
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
||||
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
||||
@@ -307,8 +359,17 @@ export class CanvasInteractions {
|
||||
this.performZoomOperation(coords.world, zoomFactor);
|
||||
}
|
||||
else {
|
||||
// Layer transformation when layers are selected
|
||||
this.handleLayerWheelTransformation(e);
|
||||
// Check if mouse is over any selected layer
|
||||
const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y);
|
||||
if (isOverSelectedLayer) {
|
||||
// Layer transformation when layers are selected and mouse is over selected layer
|
||||
this.handleLayerWheelTransformation(e);
|
||||
}
|
||||
else {
|
||||
// Zoom operation when mouse is not over selected layers
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
this.performZoomOperation(coords.world, zoomFactor);
|
||||
}
|
||||
}
|
||||
this.canvas.render();
|
||||
if (!this.canvas.maskTool.isActive) {
|
||||
@@ -316,14 +377,15 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
handleLayerWheelTransformation(e) {
|
||||
const mods = this.getModifierState(e);
|
||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||
if (e.shiftKey) {
|
||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
||||
if (mods.shift) {
|
||||
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
|
||||
}
|
||||
else {
|
||||
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
|
||||
this.handleLayerScaling(layer, mods.ctrl, e.deltaY);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -363,10 +425,12 @@ export class CanvasInteractions {
|
||||
layer.height *= scaleFactor;
|
||||
layer.x += (oldWidth - layer.width) / 2;
|
||||
layer.y += (oldHeight - layer.height) / 2;
|
||||
// Handle wheel scaling end for layers with blend area
|
||||
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
|
||||
}
|
||||
}
|
||||
calculateGridBasedScaling(oldHeight, deltaY) {
|
||||
const gridSize = 64;
|
||||
const gridSize = 64; // Grid size - could be made configurable in the future
|
||||
const direction = deltaY > 0 ? -1 : 1;
|
||||
let targetHeight;
|
||||
if (direction > 0) {
|
||||
@@ -391,6 +455,8 @@ export class CanvasInteractions {
|
||||
handleKeyDown(e) {
|
||||
if (e.key === 'Control')
|
||||
this.interaction.isCtrlPressed = true;
|
||||
if (e.key === 'Meta')
|
||||
this.interaction.isMetaPressed = true;
|
||||
if (e.key === 'Shift')
|
||||
this.interaction.isShiftPressed = true;
|
||||
if (e.key === 'Alt') {
|
||||
@@ -408,11 +474,12 @@ export class CanvasInteractions {
|
||||
return;
|
||||
}
|
||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const mods = this.getModifierState(e);
|
||||
if (mods.ctrl || mods.meta) {
|
||||
let handled = true;
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'z':
|
||||
if (e.shiftKey) {
|
||||
if (mods.shift) {
|
||||
this.canvas.redo();
|
||||
}
|
||||
else {
|
||||
@@ -439,7 +506,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
const step = mods.shift ? 10 : 1;
|
||||
let needsRender = false;
|
||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||
@@ -475,6 +542,8 @@ export class CanvasInteractions {
|
||||
handleKeyUp(e) {
|
||||
if (e.key === 'Control')
|
||||
this.interaction.isCtrlPressed = false;
|
||||
if (e.key === 'Meta')
|
||||
this.interaction.isMetaPressed = false;
|
||||
if (e.key === 'Shift')
|
||||
this.interaction.isShiftPressed = false;
|
||||
if (e.key === 'Alt')
|
||||
@@ -494,6 +563,7 @@ export class CanvasInteractions {
|
||||
handleBlur() {
|
||||
log.debug('Window lost focus, resetting key states.');
|
||||
this.interaction.isCtrlPressed = false;
|
||||
this.interaction.isMetaPressed = false;
|
||||
this.interaction.isAltPressed = false;
|
||||
this.interaction.isShiftPressed = false;
|
||||
this.interaction.isSPressed = false;
|
||||
@@ -515,6 +585,11 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
updateCursor(worldCoords) {
|
||||
// If actively rotating, show grabbing cursor
|
||||
if (this.interaction.mode === 'rotating') {
|
||||
this.canvas.canvas.style.cursor = 'grabbing';
|
||||
return;
|
||||
}
|
||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
if (transformTarget) {
|
||||
const handleName = transformTarget.handle;
|
||||
@@ -539,7 +614,10 @@ export class CanvasInteractions {
|
||||
width: layer.width, height: layer.height,
|
||||
rotation: layer.rotation,
|
||||
centerX: layer.x + layer.width / 2,
|
||||
centerY: layer.y + layer.height / 2
|
||||
centerY: layer.y + layer.height / 2,
|
||||
originalWidth: layer.originalWidth,
|
||||
originalHeight: layer.originalHeight,
|
||||
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
|
||||
};
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
if (handle === 'rot') {
|
||||
@@ -559,7 +637,9 @@ export class CanvasInteractions {
|
||||
}
|
||||
prepareForDrag(layer, worldCoords) {
|
||||
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
||||
if (this.interaction.isCtrlPressed) {
|
||||
// Support both Ctrl (Windows/Linux) and Cmd (macOS) for multi-selection
|
||||
const mods = this.getModifierState();
|
||||
if (mods.ctrl || mods.meta) {
|
||||
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||
if (index === -1) {
|
||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||
@@ -577,10 +657,9 @@ export class CanvasInteractions {
|
||||
this.interaction.mode = 'potential-drag';
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
}
|
||||
startPanningOrClearSelection(e) {
|
||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
||||
if (!this.interaction.isCtrlPressed) {
|
||||
startPanning(e, clearSelection = true) {
|
||||
// Unified panning method - can optionally clear selection
|
||||
if (clearSelection && !this.interaction.isCtrlPressed) {
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
}
|
||||
this.interaction.mode = 'panning';
|
||||
@@ -629,19 +708,16 @@ export class CanvasInteractions {
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
startPanning(e) {
|
||||
if (!this.interaction.isCtrlPressed) {
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
}
|
||||
this.interaction.mode = 'panning';
|
||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
panViewport(e) {
|
||||
const dx = e.clientX - this.interaction.panStart.x;
|
||||
const dy = e.clientY - this.interaction.panStart.y;
|
||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||
// Update stroke overlay if mask tool is drawing during pan
|
||||
if (this.canvas.maskTool.isDrawing) {
|
||||
this.canvas.maskTool.handleViewportChange();
|
||||
}
|
||||
this.canvas.render();
|
||||
this.canvas.onViewportChange?.();
|
||||
}
|
||||
@@ -692,58 +768,150 @@ export class CanvasInteractions {
|
||||
let mouseY = worldCoords.y;
|
||||
if (this.interaction.isCtrlPressed) {
|
||||
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
||||
const snappedMouseX = snapToGrid(mouseX);
|
||||
if (Math.abs(mouseX - snappedMouseX) < snapThreshold)
|
||||
mouseX = snappedMouseX;
|
||||
const snappedMouseY = snapToGrid(mouseY);
|
||||
if (Math.abs(mouseY - snappedMouseY) < snapThreshold)
|
||||
mouseY = snappedMouseY;
|
||||
mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
|
||||
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||
}
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
|
||||
if (!o)
|
||||
return;
|
||||
const handle = this.interaction.resizeHandle;
|
||||
const anchor = this.interaction.resizeAnchor;
|
||||
const rad = o.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
// Vector from anchor to mouse
|
||||
const vecX = mouseX - anchor.x;
|
||||
const vecY = mouseY - anchor.y;
|
||||
let newWidth = vecX * cos + vecY * sin;
|
||||
let newHeight = vecY * cos - vecX * sin;
|
||||
if (isShiftPressed) {
|
||||
const originalAspectRatio = o.width / o.height;
|
||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
||||
}
|
||||
else {
|
||||
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
||||
}
|
||||
}
|
||||
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||
let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||
newWidth *= signX;
|
||||
newHeight *= signY;
|
||||
// Rotate vector to align with layer's local coordinates
|
||||
let localVecX = vecX * cos + vecY * sin;
|
||||
let localVecY = vecY * cos - vecX * sin;
|
||||
// Determine sign based on handle
|
||||
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||
localVecX *= signX;
|
||||
localVecY *= signY;
|
||||
// If not a corner handle, keep original dimension
|
||||
if (signX === 0)
|
||||
newWidth = o.width;
|
||||
localVecX = o.width;
|
||||
if (signY === 0)
|
||||
newHeight = o.height;
|
||||
if (newWidth < 10)
|
||||
newWidth = 10;
|
||||
if (newHeight < 10)
|
||||
newHeight = 10;
|
||||
layer.width = newWidth;
|
||||
layer.height = newHeight;
|
||||
const deltaW = newWidth - o.width;
|
||||
const deltaH = newHeight - o.height;
|
||||
const shiftX = (deltaW / 2) * signX;
|
||||
const shiftY = (deltaH / 2) * signY;
|
||||
const worldShiftX = shiftX * cos - shiftY * sin;
|
||||
const worldShiftY = shiftX * sin + shiftY * cos;
|
||||
const newCenterX = o.centerX + worldShiftX;
|
||||
const newCenterY = o.centerY + worldShiftY;
|
||||
layer.x = newCenterX - layer.width / 2;
|
||||
layer.y = newCenterY - layer.height / 2;
|
||||
localVecY = o.height;
|
||||
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
|
||||
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
|
||||
// Calculate mouse movement since drag start, in the layer's local coordinate system.
|
||||
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
|
||||
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
|
||||
const mouseX_local = mouseX - (o.centerX ?? 0);
|
||||
const mouseY_local = mouseY - (o.centerY ?? 0);
|
||||
// Rotate mouse delta into the layer's unrotated frame
|
||||
const deltaX_world = mouseX_local - dragStartX_local;
|
||||
const deltaY_world = mouseY_local - dragStartY_local;
|
||||
let mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
||||
let mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
|
||||
if (layer.flipH) {
|
||||
mouseDeltaX_local *= -1;
|
||||
}
|
||||
if (layer.flipV) {
|
||||
mouseDeltaY_local *= -1;
|
||||
}
|
||||
// Convert the on-screen mouse delta to an image-space delta.
|
||||
const screenToImageScaleX = o.originalWidth / o.width;
|
||||
const screenToImageScaleY = o.originalHeight / o.height;
|
||||
const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
|
||||
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
|
||||
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
|
||||
// Apply the image-space delta to the appropriate edges of the crop bounds
|
||||
const isFlippedH = layer.flipH;
|
||||
const isFlippedV = layer.flipV;
|
||||
if (handle?.includes('w')) {
|
||||
if (isFlippedH)
|
||||
newCropBounds.width += delta_image_x;
|
||||
else {
|
||||
newCropBounds.x += delta_image_x;
|
||||
newCropBounds.width -= delta_image_x;
|
||||
}
|
||||
}
|
||||
if (handle?.includes('e')) {
|
||||
if (isFlippedH) {
|
||||
newCropBounds.x += delta_image_x;
|
||||
newCropBounds.width -= delta_image_x;
|
||||
}
|
||||
else
|
||||
newCropBounds.width += delta_image_x;
|
||||
}
|
||||
if (handle?.includes('n')) {
|
||||
if (isFlippedV)
|
||||
newCropBounds.height += delta_image_y;
|
||||
else {
|
||||
newCropBounds.y += delta_image_y;
|
||||
newCropBounds.height -= delta_image_y;
|
||||
}
|
||||
}
|
||||
if (handle?.includes('s')) {
|
||||
if (isFlippedV) {
|
||||
newCropBounds.y += delta_image_y;
|
||||
newCropBounds.height -= delta_image_y;
|
||||
}
|
||||
else
|
||||
newCropBounds.height += delta_image_y;
|
||||
}
|
||||
// Clamp crop bounds to stay within the original image and maintain minimum size
|
||||
if (newCropBounds.width < 1) {
|
||||
if (handle?.includes('w'))
|
||||
newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1;
|
||||
newCropBounds.width = 1;
|
||||
}
|
||||
if (newCropBounds.height < 1) {
|
||||
if (handle?.includes('n'))
|
||||
newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1;
|
||||
newCropBounds.height = 1;
|
||||
}
|
||||
if (newCropBounds.x < 0) {
|
||||
newCropBounds.width += newCropBounds.x;
|
||||
newCropBounds.x = 0;
|
||||
}
|
||||
if (newCropBounds.y < 0) {
|
||||
newCropBounds.height += newCropBounds.y;
|
||||
newCropBounds.y = 0;
|
||||
}
|
||||
if (newCropBounds.x + newCropBounds.width > o.originalWidth) {
|
||||
newCropBounds.width = o.originalWidth - newCropBounds.x;
|
||||
}
|
||||
if (newCropBounds.y + newCropBounds.height > o.originalHeight) {
|
||||
newCropBounds.height = o.originalHeight - newCropBounds.y;
|
||||
}
|
||||
layer.cropBounds = newCropBounds;
|
||||
}
|
||||
else {
|
||||
// TRANSFORM MODE: Resize the layer's main transform frame
|
||||
let newWidth = localVecX;
|
||||
let newHeight = localVecY;
|
||||
if (isShiftPressed) {
|
||||
const originalAspectRatio = o.width / o.height;
|
||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
||||
}
|
||||
else {
|
||||
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
||||
}
|
||||
}
|
||||
if (newWidth < 10)
|
||||
newWidth = 10;
|
||||
if (newHeight < 10)
|
||||
newHeight = 10;
|
||||
layer.width = newWidth;
|
||||
layer.height = newHeight;
|
||||
// Update position to keep anchor point fixed
|
||||
const deltaW = layer.width - o.width;
|
||||
const deltaH = layer.height - o.height;
|
||||
const shiftX = (deltaW / 2) * signX;
|
||||
const shiftY = (deltaH / 2) * signY;
|
||||
const worldShiftX = shiftX * cos - shiftY * sin;
|
||||
const worldShiftY = shiftX * sin + shiftY * cos;
|
||||
const newCenterX = o.centerX + worldShiftX;
|
||||
const newCenterY = o.centerY + worldShiftY;
|
||||
layer.x = newCenterX - layer.width / 2;
|
||||
layer.y = newCenterY - layer.height / 2;
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
rotateLayerFromHandle(worldCoords, isShiftPressed) {
|
||||
@@ -751,7 +919,7 @@ export class CanvasInteractions {
|
||||
if (!layer)
|
||||
return;
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined)
|
||||
if (!o)
|
||||
return;
|
||||
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
|
||||
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { generateUUID, generateUniqueFileName, createCanvas } from "./utils/CommonUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "./ErrorHandler.js";
|
||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||
// @ts-ignore
|
||||
import { app } from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
@@ -12,9 +13,21 @@ import { createDistanceFieldMaskSync } from "./utils/ImageAnalysis.js";
|
||||
const log = createModuleLogger('CanvasLayers');
|
||||
export class CanvasLayers {
|
||||
constructor(canvas) {
|
||||
this._canvasMaskCache = new Map();
|
||||
this.blendMenuElement = null;
|
||||
this.blendMenuWorldX = 0;
|
||||
this.blendMenuWorldY = 0;
|
||||
// Cache for processed images with effects applied
|
||||
this.processedImageCache = new Map();
|
||||
// Debouncing system for processed image creation
|
||||
this.processedImageDebounceTimers = new Map();
|
||||
this.PROCESSED_IMAGE_DEBOUNCE_DELAY = 1000; // 1 second
|
||||
this.globalDebounceTimer = null;
|
||||
this.lastRenderTime = 0;
|
||||
this.layersAdjustingBlendArea = new Set();
|
||||
this.layersTransformingCropBounds = new Set();
|
||||
this.layersTransformingScale = new Set();
|
||||
this.layersWheelScaling = new Set();
|
||||
this.addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required for layer creation");
|
||||
@@ -102,6 +115,8 @@ export class CanvasLayers {
|
||||
this.canvas = canvas;
|
||||
this.clipboardManager = new ClipboardManager(canvas);
|
||||
this.distanceFieldCache = new WeakMap();
|
||||
this.processedImageCache = new Map();
|
||||
this.processedImageDebounceTimers = new Map();
|
||||
this.blendModes = [
|
||||
{ name: 'normal', label: 'Normal' },
|
||||
{ name: 'multiply', label: 'Multiply' },
|
||||
@@ -121,6 +136,8 @@ export class CanvasLayers {
|
||||
this.isAdjustingOpacity = false;
|
||||
this.internalClipboard = [];
|
||||
this.clipboardPreference = 'system';
|
||||
// Load CSS for blend mode menu
|
||||
addStylesheet(getUrl('./css/blend_mode_menu.css'));
|
||||
}
|
||||
async copySelectedLayers() {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0)
|
||||
@@ -309,6 +326,10 @@ export class CanvasLayers {
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||
layer.width *= scale;
|
||||
layer.height *= scale;
|
||||
// Invalidate processed image cache when layer dimensions change
|
||||
this.invalidateProcessedImageCache(layer.id);
|
||||
// Handle wheel scaling end for layers with blend area
|
||||
this.handleWheelScalingEnd(layer);
|
||||
});
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
@@ -323,6 +344,8 @@ export class CanvasLayers {
|
||||
this.canvas.requestSaveState();
|
||||
}
|
||||
getLayerAtPosition(worldX, worldY) {
|
||||
// Always sort by zIndex so topmost is checked first
|
||||
this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
|
||||
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
|
||||
const layer = this.canvas.layers[i];
|
||||
// Skip invisible layers
|
||||
@@ -361,73 +384,551 @@ export class CanvasLayers {
|
||||
}
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
// Check if we need to apply blend area effect
|
||||
const blendArea = layer.blendArea ?? 0;
|
||||
const needsBlendAreaEffect = blendArea > 0;
|
||||
if (needsBlendAreaEffect) {
|
||||
log.debug(`Applying blend area effect for layer ${layer.id}, blendArea: ${blendArea}%`);
|
||||
// Get or create distance field mask
|
||||
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
|
||||
if (maskCanvas) {
|
||||
// Create a temporary canvas for the masked layer
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
||||
if (tempCtx) {
|
||||
// Draw the original image
|
||||
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
||||
// Apply the distance field mask using destination-in for transparency effect
|
||||
tempCtx.globalCompositeOperation = 'destination-in';
|
||||
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
|
||||
// Draw the result
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
else {
|
||||
// Fallback to normal drawing
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
// Check if we should render blend area live only in specific cases:
|
||||
// 1. When user is actively resizing in crop mode (transforming crop bounds) - only for the specific layer being transformed
|
||||
// 2. When user is actively resizing in transform mode (scaling layer) - only for the specific layer being transformed
|
||||
// 3. When blend area slider is being adjusted - only for the layer that has the menu open
|
||||
// 4. When layer is in the transforming crop bounds set (continues live rendering until cache is ready)
|
||||
// 5. When layer is in the transforming scale set (continues live rendering until cache is ready)
|
||||
const isTransformingCropBounds = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' &&
|
||||
this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id &&
|
||||
layer.cropMode;
|
||||
// Check if user is actively scaling this layer in transform mode (not crop mode)
|
||||
const isTransformingScale = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' &&
|
||||
this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id &&
|
||||
!layer.cropMode;
|
||||
// Check if this specific layer is the one being adjusted in blend area slider
|
||||
const isThisLayerBeingAdjusted = this.layersAdjustingBlendArea.has(layer.id);
|
||||
// Check if this layer is in the transforming crop bounds set (continues live rendering until cache is ready)
|
||||
const isTransformingCropBoundsSet = this.layersTransformingCropBounds.has(layer.id);
|
||||
// Check if this layer is in the transforming scale set (continues live rendering until cache is ready)
|
||||
const isTransformingScaleSet = this.layersTransformingScale.has(layer.id);
|
||||
// Check if this layer is being scaled by wheel or buttons (continues live rendering until cache is ready)
|
||||
const isWheelScaling = this.layersWheelScaling.has(layer.id);
|
||||
const shouldRenderLive = isTransformingCropBounds || isTransformingScale || isThisLayerBeingAdjusted || isTransformingCropBoundsSet || isTransformingScaleSet || isWheelScaling;
|
||||
// Check if we should use cached processed image or render live
|
||||
const processedImage = this.getProcessedImage(layer);
|
||||
// For scaling operations, try to find the BEST matching cache for this layer
|
||||
let bestMatchingCache = null;
|
||||
if (isTransformingScale || isTransformingScaleSet || isWheelScaling) {
|
||||
// Look for cache entries that match the current layer state as closely as possible
|
||||
const currentCacheKey = this.getProcessedImageCacheKey(layer);
|
||||
const currentBlendArea = layer.blendArea ?? 0;
|
||||
const currentCropKey = layer.cropBounds ?
|
||||
`${layer.cropBounds.x},${layer.cropBounds.y},${layer.cropBounds.width},${layer.cropBounds.height}` :
|
||||
'nocrop';
|
||||
// Score each cache entry to find the best match
|
||||
let bestScore = -1;
|
||||
for (const [key, image] of this.processedImageCache.entries()) {
|
||||
if (key.startsWith(layer.id + '_')) {
|
||||
let score = 0;
|
||||
// Extract blend area and crop info from cache key
|
||||
const keyParts = key.split('_');
|
||||
if (keyParts.length >= 3) {
|
||||
const cacheBlendArea = parseInt(keyParts[1]);
|
||||
const cacheCropKey = keyParts[2];
|
||||
// Score based on blend area match (higher priority)
|
||||
if (cacheBlendArea === currentBlendArea) {
|
||||
score += 100;
|
||||
}
|
||||
else {
|
||||
score -= Math.abs(cacheBlendArea - currentBlendArea);
|
||||
}
|
||||
// Score based on crop match (high priority)
|
||||
if (cacheCropKey === currentCropKey) {
|
||||
score += 200;
|
||||
}
|
||||
else {
|
||||
// Penalize mismatched crop states heavily
|
||||
score -= 150;
|
||||
}
|
||||
// Small bonus for exact match
|
||||
if (key === currentCacheKey) {
|
||||
score += 50;
|
||||
}
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatchingCache = image;
|
||||
log.debug(`Better cache found for layer ${layer.id}: ${key} (score: ${score})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fallback to normal drawing
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
if (bestMatchingCache) {
|
||||
log.debug(`Using best matching cache for layer ${layer.id} during scaling`);
|
||||
}
|
||||
}
|
||||
if (processedImage && !shouldRenderLive) {
|
||||
// Use cached processed image for all cases except specific live rendering scenarios
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(processedImage, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
else if (bestMatchingCache && (isTransformingScale || isTransformingScaleSet || isWheelScaling)) {
|
||||
// During scaling operations: use the BEST matching processed image (more efficient)
|
||||
// This ensures we always use the most appropriate blend area image during scaling
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(bestMatchingCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
else if (needsBlendAreaEffect && shouldRenderLive && !isWheelScaling) {
|
||||
// Render blend area live only when transforming crop bounds or adjusting blend area slider
|
||||
// BUT NOT during wheel scaling - that should use cached image
|
||||
this._drawLayerWithLiveBlendArea(ctx, layer);
|
||||
}
|
||||
else {
|
||||
// Normal drawing without blend area effect
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
this._drawLayerImage(ctx, layer);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
getDistanceFieldMaskSync(image, blendArea) {
|
||||
// Check cache first
|
||||
let imageCache = this.distanceFieldCache.get(image);
|
||||
if (!imageCache) {
|
||||
imageCache = new Map();
|
||||
this.distanceFieldCache.set(image, imageCache);
|
||||
/**
|
||||
* Zunifikowana funkcja do rysowania obrazu warstwy z crop
|
||||
* @param ctx Canvas context
|
||||
* @param layer Warstwa do narysowania
|
||||
* @param offsetX Przesunięcie X względem środka warstwy (domyślnie -width/2)
|
||||
* @param offsetY Przesunięcie Y względem środka warstwy (domyślnie -height/2)
|
||||
*/
|
||||
drawLayerImageWithCrop(ctx, layer, offsetX = -layer.width / 2, offsetY = -layer.height / 2) {
|
||||
// Use cropBounds if they exist, otherwise use the full image dimensions as the source
|
||||
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||
if (!layer.originalWidth || !layer.originalHeight) {
|
||||
// Fallback for older layers without original dimensions or if data is missing
|
||||
ctx.drawImage(layer.image, offsetX, offsetY, layer.width, layer.height);
|
||||
return;
|
||||
}
|
||||
let maskCanvas = imageCache.get(blendArea);
|
||||
if (!maskCanvas) {
|
||||
// Calculate the on-screen scale of the layer's transform frame
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
// Calculate the on-screen size of the cropped portion
|
||||
const dWidth = s.width * layerScaleX;
|
||||
const dHeight = s.height * layerScaleY;
|
||||
// Calculate the on-screen position of the top-left of the cropped portion
|
||||
const dX = offsetX + (s.x * layerScaleX);
|
||||
const dY = offsetY + (s.y * layerScaleY);
|
||||
ctx.drawImage(layer.image, s.x, s.y, s.width, s.height, // source rect (from original image)
|
||||
dX, dY, dWidth, dHeight // destination rect (scaled and positioned)
|
||||
);
|
||||
}
|
||||
_drawLayerImage(ctx, layer) {
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
this.drawLayerImageWithCrop(ctx, layer);
|
||||
}
|
||||
/**
|
||||
* Zunifikowana funkcja do tworzenia maski blend area dla warstwy
|
||||
* @param layer Warstwa dla której tworzymy maskę
|
||||
* @returns Obiekt zawierający maskę i jej wymiary lub null
|
||||
*/
|
||||
createBlendAreaMask(layer) {
|
||||
const blendArea = layer.blendArea ?? 0;
|
||||
if (layer.cropBounds && layer.originalWidth && layer.originalHeight) {
|
||||
// Create a cropped canvas
|
||||
const s = layer.cropBounds;
|
||||
const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height);
|
||||
if (cropCtx) {
|
||||
cropCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, 0, 0, s.width, s.height);
|
||||
// Generate distance field mask for the cropped region
|
||||
const maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea);
|
||||
if (maskCanvas) {
|
||||
return {
|
||||
maskCanvas,
|
||||
maskWidth: s.width,
|
||||
maskHeight: s.height
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// No crop, use full image
|
||||
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
|
||||
if (maskCanvas) {
|
||||
return {
|
||||
maskCanvas,
|
||||
maskWidth: layer.originalWidth || layer.width,
|
||||
maskHeight: layer.originalHeight || layer.height
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Zunifikowana funkcja do rysowania warstwy z blend area na canvas
|
||||
* @param ctx Canvas context
|
||||
* @param layer Warstwa do narysowania
|
||||
* @param offsetX Przesunięcie X (domyślnie -width/2)
|
||||
* @param offsetY Przesunięcie Y (domyślnie -height/2)
|
||||
*/
|
||||
drawLayerWithBlendArea(ctx, layer, offsetX = -layer.width / 2, offsetY = -layer.height / 2) {
|
||||
const maskInfo = this.createBlendAreaMask(layer);
|
||||
if (maskInfo) {
|
||||
const { maskCanvas, maskWidth, maskHeight } = maskInfo;
|
||||
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||
if (!layer.originalWidth || !layer.originalHeight) {
|
||||
// Fallback - just draw the image normally
|
||||
ctx.drawImage(layer.image, offsetX, offsetY, layer.width, layer.height);
|
||||
}
|
||||
else {
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
const dWidth = s.width * layerScaleX;
|
||||
const dHeight = s.height * layerScaleY;
|
||||
const dX = offsetX + (s.x * layerScaleX);
|
||||
const dY = offsetY + (s.y * layerScaleY);
|
||||
// Draw the image
|
||||
ctx.drawImage(layer.image, s.x, s.y, s.width, s.height, dX, dY, dWidth, dHeight);
|
||||
// Apply the distance field mask
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.drawImage(maskCanvas, 0, 0, maskWidth, maskHeight, dX, dY, dWidth, dHeight);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fallback - just draw the image normally
|
||||
this.drawLayerImageWithCrop(ctx, layer, offsetX, offsetY);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Draw layer with live blend area effect during user activity (original behavior)
|
||||
*/
|
||||
_drawLayerWithLiveBlendArea(ctx, layer) {
|
||||
// Create a temporary canvas for the masked layer
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
||||
if (tempCtx) {
|
||||
// Draw the layer with blend area to temp canvas
|
||||
this.drawLayerWithBlendArea(tempCtx, layer, 0, 0);
|
||||
// Draw the result with blend mode and opacity
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
else {
|
||||
// Fallback to normal drawing
|
||||
this._drawLayerImage(ctx, layer);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate a cache key for processed images based on layer properties
|
||||
*/
|
||||
getProcessedImageCacheKey(layer) {
|
||||
const blendArea = layer.blendArea ?? 0;
|
||||
const cropKey = layer.cropBounds ?
|
||||
`${layer.cropBounds.x},${layer.cropBounds.y},${layer.cropBounds.width},${layer.cropBounds.height}` :
|
||||
'nocrop';
|
||||
return `${layer.id}_${blendArea}_${cropKey}_${layer.width}_${layer.height}`;
|
||||
}
|
||||
/**
|
||||
* Get processed image with all effects applied (blend area, crop, etc.)
|
||||
* Uses live rendering for layers being actively adjusted, debounced processing for others
|
||||
*/
|
||||
getProcessedImage(layer) {
|
||||
const blendArea = layer.blendArea ?? 0;
|
||||
const needsBlendAreaEffect = blendArea > 0;
|
||||
const needsCropEffect = layer.cropBounds && layer.originalWidth && layer.originalHeight;
|
||||
// If no effects needed, return null to use normal drawing
|
||||
if (!needsBlendAreaEffect && !needsCropEffect) {
|
||||
return null;
|
||||
}
|
||||
// If this layer is being actively adjusted (blend area slider), don't use cache
|
||||
if (this.layersAdjustingBlendArea.has(layer.id)) {
|
||||
return null; // Force live rendering
|
||||
}
|
||||
// If this layer is being scaled (wheel/buttons), don't schedule new cache creation
|
||||
if (this.layersWheelScaling.has(layer.id)) {
|
||||
const cacheKey = this.getProcessedImageCacheKey(layer);
|
||||
// Only return existing cache, don't create new one
|
||||
if (this.processedImageCache.has(cacheKey)) {
|
||||
log.debug(`Using cached processed image for layer ${layer.id} during wheel scaling`);
|
||||
return this.processedImageCache.get(cacheKey) || null;
|
||||
}
|
||||
// No cache available and we're scaling - return null to use normal drawing
|
||||
return null;
|
||||
}
|
||||
const cacheKey = this.getProcessedImageCacheKey(layer);
|
||||
// Check if we have cached processed image
|
||||
if (this.processedImageCache.has(cacheKey)) {
|
||||
log.debug(`Using cached processed image for layer ${layer.id}`);
|
||||
return this.processedImageCache.get(cacheKey) || null;
|
||||
}
|
||||
// Use debounced processing - schedule creation but don't create immediately
|
||||
this.scheduleProcessedImageCreation(layer, cacheKey);
|
||||
return null; // Use original image for now until processed image is ready
|
||||
}
|
||||
/**
|
||||
* Schedule processed image creation after debounce delay
|
||||
*/
|
||||
scheduleProcessedImageCreation(layer, cacheKey) {
|
||||
// Clear existing timer for this layer
|
||||
const existingTimer = this.processedImageDebounceTimers.get(layer.id);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
// Schedule new timer
|
||||
const timer = window.setTimeout(() => {
|
||||
log.info(`Creating debounced processed image for layer ${layer.id}`);
|
||||
try {
|
||||
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
|
||||
maskCanvas = createDistanceFieldMaskSync(image, blendArea);
|
||||
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||
imageCache.set(blendArea, maskCanvas);
|
||||
const processedImage = this.createProcessedImage(layer);
|
||||
if (processedImage) {
|
||||
this.processedImageCache.set(cacheKey, processedImage);
|
||||
log.debug(`Cached debounced processed image for layer ${layer.id}`);
|
||||
// Trigger re-render to show the processed image
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error('Failed to create distance field mask:', error);
|
||||
log.error('Failed to create debounced processed image:', error);
|
||||
}
|
||||
// Clean up timer
|
||||
this.processedImageDebounceTimers.delete(layer.id);
|
||||
}, this.PROCESSED_IMAGE_DEBOUNCE_DELAY);
|
||||
this.processedImageDebounceTimers.set(layer.id, timer);
|
||||
}
|
||||
/**
|
||||
* Update last render time to track activity for debouncing
|
||||
*/
|
||||
updateLastRenderTime() {
|
||||
this.lastRenderTime = Date.now();
|
||||
log.debug(`Updated last render time for debouncing: ${this.lastRenderTime}`);
|
||||
}
|
||||
/**
|
||||
* Process all pending images immediately when user stops interacting
|
||||
*/
|
||||
processPendingImages() {
|
||||
// Clear all pending timers and process immediately
|
||||
for (const [layerId, timer] of this.processedImageDebounceTimers.entries()) {
|
||||
clearTimeout(timer);
|
||||
// Find the layer and process it
|
||||
const layer = this.canvas.layers.find(l => l.id === layerId);
|
||||
if (layer) {
|
||||
const cacheKey = this.getProcessedImageCacheKey(layer);
|
||||
if (!this.processedImageCache.has(cacheKey)) {
|
||||
try {
|
||||
const processedImage = this.createProcessedImage(layer);
|
||||
if (processedImage) {
|
||||
this.processedImageCache.set(cacheKey, processedImage);
|
||||
log.debug(`Processed pending image for layer ${layer.id}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Failed to process pending image for layer ${layer.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.processedImageDebounceTimers.clear();
|
||||
// Trigger re-render to show all processed images
|
||||
if (this.processedImageDebounceTimers.size > 0) {
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create a new processed image with all effects applied
|
||||
*/
|
||||
createProcessedImage(layer) {
|
||||
const blendArea = layer.blendArea ?? 0;
|
||||
const needsBlendAreaEffect = blendArea > 0;
|
||||
// Create a canvas for the processed image
|
||||
const { canvas: processedCanvas, ctx: processedCtx } = createCanvas(layer.width, layer.height);
|
||||
if (!processedCtx)
|
||||
return null;
|
||||
if (needsBlendAreaEffect) {
|
||||
// Use the unified blend area drawing function
|
||||
this.drawLayerWithBlendArea(processedCtx, layer, 0, 0);
|
||||
}
|
||||
else {
|
||||
// Just apply crop effect without blend area
|
||||
this.drawLayerImageWithCrop(processedCtx, layer, 0, 0);
|
||||
}
|
||||
// Convert canvas to image
|
||||
const processedImage = new Image();
|
||||
processedImage.src = processedCanvas.toDataURL();
|
||||
return processedImage;
|
||||
}
|
||||
/**
|
||||
* Helper method to draw layer image to a specific canvas context (position 0,0)
|
||||
* Uses the unified drawLayerImageWithCrop function
|
||||
*/
|
||||
_drawLayerImageToCanvas(ctx, layer) {
|
||||
this.drawLayerImageWithCrop(ctx, layer, 0, 0);
|
||||
}
|
||||
/**
|
||||
* Invalidate processed image cache for a specific layer
|
||||
*/
|
||||
invalidateProcessedImageCache(layerId) {
|
||||
const keysToDelete = [];
|
||||
for (const key of this.processedImageCache.keys()) {
|
||||
if (key.startsWith(`${layerId}_`)) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
keysToDelete.forEach(key => {
|
||||
this.processedImageCache.delete(key);
|
||||
log.debug(`Invalidated processed image cache for key: ${key}`);
|
||||
});
|
||||
// Also clear any pending timers for this layer
|
||||
const existingTimer = this.processedImageDebounceTimers.get(layerId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
this.processedImageDebounceTimers.delete(layerId);
|
||||
log.debug(`Cleared pending timer for layer ${layerId}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clear all processed image cache
|
||||
*/
|
||||
clearProcessedImageCache() {
|
||||
this.processedImageCache.clear();
|
||||
// Clear all pending timers
|
||||
for (const timer of this.processedImageDebounceTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.processedImageDebounceTimers.clear();
|
||||
log.info('Cleared all processed image cache and pending timers');
|
||||
}
|
||||
/**
|
||||
* Zunifikowana funkcja do obsługi transformacji końcowych
|
||||
* @param layer Warstwa do przetworzenia
|
||||
* @param transformType Typ transformacji (crop, scale, wheel)
|
||||
* @param delay Opóźnienie w ms (domyślnie 0)
|
||||
*/
|
||||
handleTransformEnd(layer, transformType, delay = 0) {
|
||||
if (!layer.blendArea)
|
||||
return;
|
||||
const layerId = layer.id;
|
||||
const cacheKey = this.getProcessedImageCacheKey(layer);
|
||||
// Add to appropriate transforming set to continue live rendering
|
||||
let transformingSet;
|
||||
let transformName;
|
||||
switch (transformType) {
|
||||
case 'crop':
|
||||
transformingSet = this.layersTransformingCropBounds;
|
||||
transformName = 'crop bounds';
|
||||
break;
|
||||
case 'scale':
|
||||
transformingSet = this.layersTransformingScale;
|
||||
transformName = 'scale';
|
||||
break;
|
||||
case 'wheel':
|
||||
transformingSet = this.layersWheelScaling;
|
||||
transformName = 'wheel';
|
||||
break;
|
||||
}
|
||||
transformingSet.add(layerId);
|
||||
// Create processed image asynchronously with optional delay
|
||||
const executeTransform = () => {
|
||||
try {
|
||||
const processedImage = this.createProcessedImage(layer);
|
||||
if (processedImage) {
|
||||
this.processedImageCache.set(cacheKey, processedImage);
|
||||
log.debug(`Cached processed image for layer ${layerId} after ${transformName} transform`);
|
||||
// Only now remove from live rendering set and trigger re-render
|
||||
transformingSet.delete(layerId);
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Failed to create processed image after ${transformName} transform:`, error);
|
||||
// Fallback: remove from live rendering even if cache creation failed
|
||||
transformingSet.delete(layerId);
|
||||
}
|
||||
};
|
||||
if (delay > 0) {
|
||||
// For wheel scaling, use debounced approach
|
||||
const timerKey = `${layerId}_${transformType}scaling`;
|
||||
const existingTimer = this.processedImageDebounceTimers.get(timerKey);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
log.debug(`Creating new cache for layer ${layerId} after ${transformName} scaling stopped`);
|
||||
executeTransform();
|
||||
this.processedImageDebounceTimers.delete(timerKey);
|
||||
}, delay);
|
||||
this.processedImageDebounceTimers.set(timerKey, timer);
|
||||
}
|
||||
else {
|
||||
// For crop and scale, use immediate async approach
|
||||
setTimeout(executeTransform, 0);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Handle end of crop bounds transformation - create cache asynchronously but keep live rendering until ready
|
||||
*/
|
||||
handleCropBoundsTransformEnd(layer) {
|
||||
if (!layer.cropMode || !layer.blendArea)
|
||||
return;
|
||||
this.handleTransformEnd(layer, 'crop', 0);
|
||||
}
|
||||
/**
|
||||
* Handle end of scale transformation - create cache asynchronously but keep live rendering until ready
|
||||
*/
|
||||
handleScaleTransformEnd(layer) {
|
||||
if (!layer.blendArea)
|
||||
return;
|
||||
this.handleTransformEnd(layer, 'scale', 0);
|
||||
}
|
||||
/**
|
||||
* Handle end of wheel/button scaling - use debounced cache creation
|
||||
*/
|
||||
handleWheelScalingEnd(layer) {
|
||||
if (!layer.blendArea)
|
||||
return;
|
||||
this.handleTransformEnd(layer, 'wheel', 500);
|
||||
}
|
||||
getDistanceFieldMaskSync(imageOrCanvas, blendArea) {
|
||||
// Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references)
|
||||
let cacheKey = imageOrCanvas;
|
||||
if (imageOrCanvas instanceof HTMLCanvasElement) {
|
||||
// For canvases, use a Map on this instance (not WeakMap)
|
||||
if (!this._canvasMaskCache)
|
||||
this._canvasMaskCache = new Map();
|
||||
let canvasCache = this._canvasMaskCache.get(imageOrCanvas);
|
||||
if (!canvasCache) {
|
||||
canvasCache = new Map();
|
||||
this._canvasMaskCache.set(imageOrCanvas, canvasCache);
|
||||
}
|
||||
if (canvasCache.has(blendArea)) {
|
||||
log.info(`Using cached distance field mask for blendArea: ${blendArea}% (canvas)`);
|
||||
return canvasCache.get(blendArea) || null;
|
||||
}
|
||||
try {
|
||||
log.info(`Creating distance field mask for blendArea: ${blendArea}% (canvas)`);
|
||||
const maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, blendArea);
|
||||
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||
canvasCache.set(blendArea, maskCanvas);
|
||||
return maskCanvas;
|
||||
}
|
||||
catch (error) {
|
||||
log.error('Failed to create distance field mask (canvas):', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
|
||||
// For images, use the original WeakMap cache
|
||||
let imageCache = this.distanceFieldCache.get(imageOrCanvas);
|
||||
if (!imageCache) {
|
||||
imageCache = new Map();
|
||||
this.distanceFieldCache.set(imageOrCanvas, imageCache);
|
||||
}
|
||||
let maskCanvas = imageCache.get(blendArea);
|
||||
if (!maskCanvas) {
|
||||
try {
|
||||
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
|
||||
maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, blendArea);
|
||||
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||
imageCache.set(blendArea, maskCanvas);
|
||||
}
|
||||
catch (error) {
|
||||
log.error('Failed to create distance field mask:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
|
||||
}
|
||||
return maskCanvas;
|
||||
}
|
||||
return maskCanvas;
|
||||
}
|
||||
_drawLayers(ctx, layers, options = {}) {
|
||||
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
@@ -460,17 +961,13 @@ export class CanvasLayers {
|
||||
}
|
||||
async getLayerImageData(layer) {
|
||||
try {
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height, '2d', { willReadFrequently: true });
|
||||
const width = layer.originalWidth || layer.width;
|
||||
const height = layer.originalHeight || layer.height;
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||
if (!tempCtx)
|
||||
throw new Error("Could not create canvas context");
|
||||
// We need to draw the layer relative to the new canvas, so we "move" it to 0,0
|
||||
// by creating a temporary layer object for drawing.
|
||||
const layerToDraw = {
|
||||
...layer,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
this._drawLayer(tempCtx, layerToDraw);
|
||||
// Use original image directly to ensure full quality
|
||||
tempCtx.drawImage(layer.image, 0, 0, width, height);
|
||||
const dataUrl = tempCanvas.toDataURL('image/png');
|
||||
if (!dataUrl.startsWith('data:image/png;base64,')) {
|
||||
throw new Error("Invalid image data format");
|
||||
@@ -527,30 +1024,54 @@ export class CanvasLayers {
|
||||
this.canvas.saveState();
|
||||
}
|
||||
getHandles(layer) {
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const layerCenterX = layer.x + layer.width / 2;
|
||||
const layerCenterY = 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;
|
||||
let handleCenterX, handleCenterY, halfW, halfH;
|
||||
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||
// CROP MODE: Handles are relative to the cropped area
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
const cropRectW = layer.cropBounds.width * layerScaleX;
|
||||
const cropRectH = layer.cropBounds.height * layerScaleY;
|
||||
// Effective crop bounds start position, accounting for flips.
|
||||
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;
|
||||
// Center of the CROP rectangle in the layer's local, un-rotated space
|
||||
const cropCenterX_local = (-layer.width / 2) + ((effectiveCropX + layer.cropBounds.width / 2) * layerScaleX);
|
||||
const cropCenterY_local = (-layer.height / 2) + ((effectiveCropY + layer.cropBounds.height / 2) * layerScaleY);
|
||||
// Rotate this local center to find the world-space center of the crop rect
|
||||
handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin);
|
||||
handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos);
|
||||
halfW = cropRectW / 2;
|
||||
halfH = cropRectH / 2;
|
||||
}
|
||||
else {
|
||||
// TRANSFORM MODE: Handles are relative to the full layer transform frame
|
||||
handleCenterX = layerCenterX;
|
||||
handleCenterY = layerCenterY;
|
||||
halfW = layer.width / 2;
|
||||
halfH = layer.height / 2;
|
||||
}
|
||||
const localHandles = {
|
||||
'n': { x: 0, y: -halfH },
|
||||
'ne': { x: halfW, y: -halfH },
|
||||
'e': { x: halfW, y: 0 },
|
||||
'se': { x: halfW, y: halfH },
|
||||
's': { x: 0, y: halfH },
|
||||
'sw': { x: -halfW, y: halfH },
|
||||
'w': { x: -halfW, y: 0 },
|
||||
'nw': { x: -halfW, y: -halfH },
|
||||
'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH },
|
||||
'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH },
|
||||
's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH },
|
||||
'w': { x: -halfW, y: 0 }, 'nw': { x: -halfW, y: -halfH },
|
||||
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
|
||||
};
|
||||
const worldHandles = {};
|
||||
for (const key in localHandles) {
|
||||
const p = localHandles[key];
|
||||
worldHandles[key] = {
|
||||
x: centerX + (p.x * cos - p.y * sin),
|
||||
y: centerY + (p.x * sin + p.y * cos)
|
||||
x: handleCenterX + (p.x * cos - p.y * sin),
|
||||
y: handleCenterY + (p.x * sin + p.y * cos)
|
||||
};
|
||||
}
|
||||
return worldHandles;
|
||||
@@ -633,65 +1154,14 @@ export class CanvasLayers {
|
||||
const menu = document.createElement('div');
|
||||
this.blendMenuElement = menu;
|
||||
menu.id = 'blend-mode-menu';
|
||||
menu.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
min-width: 200px;
|
||||
`;
|
||||
const titleBar = document.createElement('div');
|
||||
titleBar.style.cssText = `
|
||||
background: #3a3a3a;
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 3px 3px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
titleBar.className = 'blend-menu-title-bar';
|
||||
const titleText = document.createElement('span');
|
||||
titleText.textContent = `Blend Mode: ${selectedLayer.name}`;
|
||||
titleText.style.cssText = `
|
||||
flex: 1;
|
||||
cursor: move;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
titleText.className = 'blend-menu-title-text';
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.textContent = '×';
|
||||
closeButton.style.cssText = `
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s;
|
||||
`;
|
||||
closeButton.onmouseover = () => {
|
||||
closeButton.style.backgroundColor = '#4a4a4a';
|
||||
};
|
||||
closeButton.onmouseout = () => {
|
||||
closeButton.style.backgroundColor = 'transparent';
|
||||
};
|
||||
closeButton.className = 'blend-menu-close-button';
|
||||
closeButton.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeBlendModeMenu();
|
||||
@@ -699,27 +1169,55 @@ export class CanvasLayers {
|
||||
titleBar.appendChild(titleText);
|
||||
titleBar.appendChild(closeButton);
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `padding: 5px;`;
|
||||
content.className = 'blend-menu-content';
|
||||
menu.appendChild(titleBar);
|
||||
menu.appendChild(content);
|
||||
const blendAreaContainer = document.createElement('div');
|
||||
blendAreaContainer.style.cssText = `padding: 5px 10px; border-bottom: 1px solid #4a4a4a;`;
|
||||
blendAreaContainer.className = 'blend-area-container';
|
||||
const blendAreaLabel = document.createElement('label');
|
||||
blendAreaLabel.textContent = 'Blend Area';
|
||||
blendAreaLabel.style.color = 'white';
|
||||
blendAreaLabel.className = 'blend-area-label';
|
||||
const blendAreaSlider = document.createElement('input');
|
||||
blendAreaSlider.type = 'range';
|
||||
blendAreaSlider.min = '0';
|
||||
blendAreaSlider.max = '100';
|
||||
blendAreaSlider.className = 'blend-area-slider';
|
||||
blendAreaSlider.value = selectedLayer?.blendArea?.toString() ?? '0';
|
||||
blendAreaSlider.oninput = () => {
|
||||
if (selectedLayer) {
|
||||
const newValue = parseInt(blendAreaSlider.value, 10);
|
||||
selectedLayer.blendArea = newValue;
|
||||
// Set flag to enable live blend area rendering for this specific layer
|
||||
this.layersAdjustingBlendArea.add(selectedLayer.id);
|
||||
// Invalidate processed image cache when blend area changes
|
||||
this.invalidateProcessedImageCache(selectedLayer.id);
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
blendAreaSlider.addEventListener('change', () => {
|
||||
// When user stops adjusting, create cache asynchronously but keep live rendering until cache is ready
|
||||
if (selectedLayer) {
|
||||
const layerId = selectedLayer.id;
|
||||
const cacheKey = this.getProcessedImageCacheKey(selectedLayer);
|
||||
// Create processed image asynchronously
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const processedImage = this.createProcessedImage(selectedLayer);
|
||||
if (processedImage) {
|
||||
this.processedImageCache.set(cacheKey, processedImage);
|
||||
log.debug(`Cached processed image for layer ${layerId} after slider change`);
|
||||
// Only now remove from live rendering set and trigger re-render
|
||||
this.layersAdjustingBlendArea.delete(layerId);
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error('Failed to create processed image after slider change:', error);
|
||||
// Fallback: remove from live rendering even if cache creation failed
|
||||
this.layersAdjustingBlendArea.delete(layerId);
|
||||
}
|
||||
}, 0); // Use setTimeout to make it asynchronous
|
||||
}
|
||||
this.canvas.saveState();
|
||||
});
|
||||
blendAreaContainer.appendChild(blendAreaLabel);
|
||||
@@ -754,20 +1252,19 @@ export class CanvasLayers {
|
||||
this.blendModes.forEach((mode) => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'blend-mode-container';
|
||||
container.style.cssText = `margin-bottom: 5px;`;
|
||||
const option = document.createElement('div');
|
||||
option.style.cssText = `padding: 5px 10px; color: white; cursor: pointer; transition: background-color 0.2s;`;
|
||||
option.className = 'blend-mode-option';
|
||||
option.textContent = `${mode.label} (${mode.name})`;
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = '0';
|
||||
slider.max = '100';
|
||||
slider.className = 'blend-opacity-slider';
|
||||
const selectedLayer = this.canvas.canvasSelection.selectedLayers[0];
|
||||
slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100';
|
||||
slider.style.cssText = `width: 100%; margin: 5px 0; display: none;`;
|
||||
if (selectedLayer && selectedLayer.blendMode === mode.name) {
|
||||
slider.style.display = 'block';
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
container.classList.add('active');
|
||||
option.classList.add('active');
|
||||
}
|
||||
option.onclick = () => {
|
||||
// Re-check selected layer at the time of click
|
||||
@@ -775,19 +1272,17 @@ export class CanvasLayers {
|
||||
if (!currentSelectedLayer) {
|
||||
return;
|
||||
}
|
||||
// Hide only the opacity sliders within other blend mode containers
|
||||
// Remove active class from all containers and options
|
||||
content.querySelectorAll('.blend-mode-container').forEach(c => {
|
||||
const opacitySlider = c.querySelector('input[type="range"]');
|
||||
if (opacitySlider) {
|
||||
opacitySlider.style.display = 'none';
|
||||
}
|
||||
const optionDiv = c.querySelector('div');
|
||||
c.classList.remove('active');
|
||||
const optionDiv = c.querySelector('.blend-mode-option');
|
||||
if (optionDiv) {
|
||||
optionDiv.style.backgroundColor = '';
|
||||
optionDiv.classList.remove('active');
|
||||
}
|
||||
});
|
||||
slider.style.display = 'block';
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
// Add active class to current container and option
|
||||
container.classList.add('active');
|
||||
option.classList.add('active');
|
||||
currentSelectedLayer.blendMode = mode.name;
|
||||
this.canvas.render();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||
const log = createModuleLogger('CanvasLayersPanel');
|
||||
export class CanvasLayersPanel {
|
||||
constructor(canvas) {
|
||||
@@ -18,6 +19,8 @@ export class CanvasLayersPanel {
|
||||
this.handleDrop = this.handleDrop.bind(this);
|
||||
// Preload icons
|
||||
this.initializeIcons();
|
||||
// Load CSS for layers panel
|
||||
addStylesheet(getUrl('./css/layers_panel.css'));
|
||||
log.info('CanvasLayersPanel initialized');
|
||||
}
|
||||
async initializeIcons() {
|
||||
@@ -31,22 +34,15 @@ export class CanvasLayersPanel {
|
||||
}
|
||||
createIconElement(toolName, size = 16) {
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
iconContainer.className = 'icon-container';
|
||||
iconContainer.style.width = `${size}px`;
|
||||
iconContainer.style.height = `${size}px`;
|
||||
const icon = iconLoader.getIcon(toolName);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode();
|
||||
img.style.cssText = `
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
filter: brightness(0) invert(1);
|
||||
`;
|
||||
img.style.width = `${size}px`;
|
||||
img.style.height = `${size}px`;
|
||||
iconContainer.appendChild(img);
|
||||
}
|
||||
else if (icon instanceof HTMLCanvasElement) {
|
||||
@@ -59,9 +55,9 @@ export class CanvasLayersPanel {
|
||||
}
|
||||
else {
|
||||
// Fallback text
|
||||
iconContainer.classList.add('fallback-text');
|
||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||
iconContainer.style.color = '#ffffff';
|
||||
}
|
||||
return iconContainer;
|
||||
}
|
||||
@@ -72,24 +68,15 @@ export class CanvasLayersPanel {
|
||||
else {
|
||||
// Create a "hidden" version of the visibility icon
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
`;
|
||||
iconContainer.className = 'icon-container visibility-hidden';
|
||||
iconContainer.style.width = '16px';
|
||||
iconContainer.style.height = '16px';
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode();
|
||||
img.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.3;
|
||||
`;
|
||||
img.style.width = '16px';
|
||||
img.style.height = '16px';
|
||||
iconContainer.appendChild(img);
|
||||
}
|
||||
else if (icon instanceof HTMLCanvasElement) {
|
||||
@@ -103,9 +90,9 @@ export class CanvasLayersPanel {
|
||||
}
|
||||
else {
|
||||
// Fallback
|
||||
iconContainer.classList.add('fallback-text');
|
||||
iconContainer.textContent = 'H';
|
||||
iconContainer.style.fontSize = '10px';
|
||||
iconContainer.style.color = '#888888';
|
||||
}
|
||||
return iconContainer;
|
||||
}
|
||||
@@ -126,7 +113,6 @@ export class CanvasLayersPanel {
|
||||
</div>
|
||||
`;
|
||||
this.layersContainer = this.container.querySelector('#layers-container');
|
||||
this.injectStyles();
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||
@@ -140,212 +126,6 @@ export class CanvasLayersPanel {
|
||||
log.debug('Panel structure created');
|
||||
return this.container;
|
||||
}
|
||||
injectStyles() {
|
||||
const styleId = 'layers-panel-styles';
|
||||
if (document.getElementById(styleId)) {
|
||||
return; // Style już istnieją
|
||||
}
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.layers-panel {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layers-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.layers-panel-title {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layers-panel-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layers-btn {
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.layers-btn:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.layers-btn:active {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layers-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 4px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.layer-row.selected {
|
||||
background: #2d5aa0 !important;
|
||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||
}
|
||||
|
||||
.layer-row.dragging {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
|
||||
.layer-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layer-thumbnail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name.editing {
|
||||
background: #4a4a4a;
|
||||
border: 1px solid #6a6a6a;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drag-insertion-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #4a7bc8;
|
||||
border-radius: 1px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
log.debug('Styles injected');
|
||||
}
|
||||
setupControlButtons() {
|
||||
if (!this.container)
|
||||
return;
|
||||
@@ -359,6 +139,8 @@ export class CanvasLayersPanel {
|
||||
log.info('Delete layer button clicked');
|
||||
this.deleteSelectedLayers();
|
||||
});
|
||||
// Initial button state update
|
||||
this.updateButtonStates();
|
||||
}
|
||||
renderLayers() {
|
||||
if (!this.layersContainer) {
|
||||
@@ -448,6 +230,7 @@ export class CanvasLayersPanel {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
||||
this.canvas.updateSelection(newSelection);
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
});
|
||||
layerRow.addEventListener('dblclick', (e) => {
|
||||
@@ -480,6 +263,7 @@ export class CanvasLayersPanel {
|
||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||
}
|
||||
startEditingLayerName(nameElement, layer) {
|
||||
@@ -660,12 +444,29 @@ export class CanvasLayersPanel {
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
|
||||
*/
|
||||
updateButtonStates() {
|
||||
if (!this.container)
|
||||
return;
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = !hasSelectedLayers;
|
||||
deleteBtn.title = hasSelectedLayers
|
||||
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
|
||||
: 'No layers selected';
|
||||
}
|
||||
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
/**
|
||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||
*/
|
||||
onSelectionChanged() {
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
destroy() {
|
||||
if (this.container && this.container.parentNode) {
|
||||
|
||||
@@ -7,6 +7,9 @@ export class CanvasRenderer {
|
||||
this.lastRenderTime = 0;
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
// Initialize overlay canvases
|
||||
this.initOverlay();
|
||||
this.initStrokeOverlay();
|
||||
}
|
||||
/**
|
||||
* Helper function to draw text with background at world coordinates
|
||||
@@ -102,10 +105,12 @@ export class CanvasRenderer {
|
||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||
ctx.save();
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
// In draw mask mode, use the previewOpacity value from the slider
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||
}
|
||||
else {
|
||||
// When not in draw mask mode, show mask at full opacity
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
@@ -158,6 +163,11 @@ export class CanvasRenderer {
|
||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||
}
|
||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||
// Ensure overlay canvases are in DOM and properly sized
|
||||
this.addOverlayToDOM();
|
||||
this.updateOverlaySize();
|
||||
this.addStrokeOverlayToDOM();
|
||||
this.updateStrokeOverlaySize();
|
||||
// Update Batch Preview UI positions
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager) => {
|
||||
@@ -431,39 +441,76 @@ export class CanvasRenderer {
|
||||
drawSelectionFrame(ctx, layer) {
|
||||
const lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = lineWidth;
|
||||
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
// Górna krawędź
|
||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||
// Prawa krawędź
|
||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||
// Dolna krawędź
|
||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||
// Lewa krawędź
|
||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -layer.height / 2);
|
||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||
ctx.stroke();
|
||||
// Rysuj uchwyty
|
||||
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||
// --- CROP MODE ---
|
||||
ctx.lineWidth = lineWidth;
|
||||
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||
ctx.strokeStyle = '#007bff';
|
||||
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
ctx.setLineDash([]);
|
||||
// 2. Draw solid blue line for the crop bounds
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
const s = layer.cropBounds;
|
||||
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||
const cropRectW = s.width * layerScaleX;
|
||||
const cropRectH = s.height * layerScaleY;
|
||||
ctx.strokeStyle = '#007bff'; // Solid blue
|
||||
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
|
||||
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
|
||||
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
|
||||
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
|
||||
}
|
||||
else {
|
||||
// --- TRANSFORM MODE ---
|
||||
ctx.strokeStyle = '#00ff00'; // Green
|
||||
ctx.lineWidth = lineWidth;
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
// Draw adaptive solid green line for transform frame
|
||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||
// Draw line to rotation handle
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
const startY = layer.flipV ? halfH : -halfH;
|
||||
const endY = startY + (layer.flipV ? 1 : -1) * (20 / this.canvas.viewport.zoom);
|
||||
ctx.moveTo(0, startY);
|
||||
ctx.lineTo(0, endY);
|
||||
ctx.stroke();
|
||||
}
|
||||
// --- DRAW HANDLES (Unified Logic) ---
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
for (const key in handles) {
|
||||
// Skip rotation handle in crop mode
|
||||
if (layer.cropMode && key === 'rot')
|
||||
continue;
|
||||
const point = handles[key];
|
||||
ctx.beginPath();
|
||||
const localX = point.x - (layer.x + layer.width / 2);
|
||||
const localY = point.y - (layer.y + layer.height / 2);
|
||||
// The handle position is already in world space.
|
||||
// We need to convert it to the layer's local, un-rotated space.
|
||||
const dx = point.x - centerX;
|
||||
const dy = point.y - centerY;
|
||||
// "Un-rotate" the position to get it in the layer's local, un-rotated space
|
||||
const rad = -layer.rotation * Math.PI / 180;
|
||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const localX = dx * cos - dy * sin;
|
||||
const localY = dx * sin + dy * cos;
|
||||
// The context is already flipped. We need to flip the coordinates
|
||||
// to match the visual transformation, so the arc is drawn in the correct place.
|
||||
const finalX = localX * (layer.flipH ? -1 : 1);
|
||||
const finalY = localY * (layer.flipV ? -1 : 1);
|
||||
ctx.beginPath();
|
||||
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
@@ -546,4 +593,243 @@ export class CanvasRenderer {
|
||||
padding: 8
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Initialize overlay canvas for lightweight overlays like brush cursor
|
||||
*/
|
||||
initOverlay() {
|
||||
// Setup overlay canvas to match main canvas
|
||||
this.updateOverlaySize();
|
||||
// Position overlay canvas on top of main canvas
|
||||
this.canvas.overlayCanvas.style.position = 'absolute';
|
||||
this.canvas.overlayCanvas.style.left = '0px';
|
||||
this.canvas.overlayCanvas.style.top = '0px';
|
||||
this.canvas.overlayCanvas.style.pointerEvents = 'none';
|
||||
this.canvas.overlayCanvas.style.zIndex = '20'; // Above other overlays
|
||||
// Add overlay to DOM when main canvas is added
|
||||
this.addOverlayToDOM();
|
||||
log.debug('Overlay canvas initialized');
|
||||
}
|
||||
/**
|
||||
* Add overlay canvas to DOM if main canvas has a parent
|
||||
*/
|
||||
addOverlayToDOM() {
|
||||
if (this.canvas.canvas.parentElement && !this.canvas.overlayCanvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.canvas.overlayCanvas);
|
||||
log.debug('Overlay canvas added to DOM');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Update overlay canvas size to match main canvas
|
||||
*/
|
||||
updateOverlaySize() {
|
||||
if (this.canvas.overlayCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||
this.canvas.overlayCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||
this.canvas.overlayCanvas.width = Math.max(1, this.canvas.canvas.clientWidth);
|
||||
this.canvas.overlayCanvas.height = Math.max(1, this.canvas.canvas.clientHeight);
|
||||
log.debug(`Overlay canvas resized to ${this.canvas.overlayCanvas.width}x${this.canvas.overlayCanvas.height}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clear overlay canvas
|
||||
*/
|
||||
clearOverlay() {
|
||||
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
|
||||
}
|
||||
/**
|
||||
* Initialize a dedicated overlay for real-time mask stroke preview
|
||||
*/
|
||||
initStrokeOverlay() {
|
||||
// Create canvas if not created yet
|
||||
if (!this.strokeOverlayCanvas) {
|
||||
this.strokeOverlayCanvas = document.createElement('canvas');
|
||||
const ctx = this.strokeOverlayCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get 2D context for stroke overlay canvas');
|
||||
}
|
||||
this.strokeOverlayCtx = ctx;
|
||||
}
|
||||
// Size match main canvas
|
||||
this.updateStrokeOverlaySize();
|
||||
// Position above main canvas but below cursor overlay
|
||||
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||
this.strokeOverlayCanvas.style.left = '1px';
|
||||
this.strokeOverlayCanvas.style.top = '1px';
|
||||
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||
// Opacity is now controlled by MaskTool.previewOpacity
|
||||
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
|
||||
// Add to DOM
|
||||
this.addStrokeOverlayToDOM();
|
||||
log.debug('Stroke overlay canvas initialized');
|
||||
}
|
||||
/**
|
||||
* Add stroke overlay canvas to DOM if needed
|
||||
*/
|
||||
addStrokeOverlayToDOM() {
|
||||
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
|
||||
log.debug('Stroke overlay canvas added to DOM');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Ensure stroke overlay size matches main canvas
|
||||
*/
|
||||
updateStrokeOverlaySize() {
|
||||
const w = Math.max(1, this.canvas.canvas.clientWidth);
|
||||
const h = Math.max(1, this.canvas.canvas.clientHeight);
|
||||
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
|
||||
this.strokeOverlayCanvas.width = w;
|
||||
this.strokeOverlayCanvas.height = h;
|
||||
log.debug(`Stroke overlay resized to ${w}x${h}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clear the stroke overlay
|
||||
*/
|
||||
clearMaskStrokeOverlay() {
|
||||
if (!this.strokeOverlayCtx)
|
||||
return;
|
||||
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
|
||||
}
|
||||
/**
|
||||
* Draw a preview stroke segment onto the stroke overlay in screen space
|
||||
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
|
||||
*/
|
||||
drawMaskStrokeSegment(startWorld, endWorld) {
|
||||
// Ensure overlay is present and sized
|
||||
this.updateStrokeOverlaySize();
|
||||
const zoom = this.canvas.viewport.zoom;
|
||||
const toScreen = (p) => ({
|
||||
x: (p.x - this.canvas.viewport.x) * zoom,
|
||||
y: (p.y - this.canvas.viewport.y) * zoom
|
||||
});
|
||||
const startScreen = toScreen(startWorld);
|
||||
const endScreen = toScreen(endWorld);
|
||||
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
|
||||
const hardness = this.canvas.maskTool.brushHardness;
|
||||
const strength = this.canvas.maskTool.brushStrength;
|
||||
// If strength is 0, don't draw anything
|
||||
if (strength <= 0) {
|
||||
return;
|
||||
}
|
||||
this.strokeOverlayCtx.save();
|
||||
// Draw line segment exactly as MaskTool does
|
||||
this.strokeOverlayCtx.beginPath();
|
||||
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
|
||||
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
|
||||
// Match the gradient setup from MaskTool's drawLineOnChunk
|
||||
if (hardness === 1) {
|
||||
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
|
||||
}
|
||||
else {
|
||||
const innerRadius = brushRadius * hardness;
|
||||
const gradient = this.strokeOverlayCtx.createRadialGradient(endScreen.x, endScreen.y, innerRadius, endScreen.x, endScreen.y, brushRadius);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||
this.strokeOverlayCtx.strokeStyle = gradient;
|
||||
}
|
||||
// Match line properties from MaskTool
|
||||
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
|
||||
this.strokeOverlayCtx.lineCap = 'round';
|
||||
this.strokeOverlayCtx.lineJoin = 'round';
|
||||
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
|
||||
this.strokeOverlayCtx.stroke();
|
||||
this.strokeOverlayCtx.restore();
|
||||
}
|
||||
/**
|
||||
* Redraws the entire stroke overlay from world coordinates
|
||||
* Used when viewport changes during drawing to maintain visual consistency
|
||||
*/
|
||||
redrawMaskStrokeOverlay(strokePoints) {
|
||||
if (strokePoints.length < 2)
|
||||
return;
|
||||
// Clear the overlay first
|
||||
this.clearMaskStrokeOverlay();
|
||||
// Redraw all segments with current viewport
|
||||
for (let i = 1; i < strokePoints.length; i++) {
|
||||
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
|
||||
* @param worldPoint World coordinates of cursor
|
||||
*/
|
||||
drawMaskBrushCursor(worldPoint) {
|
||||
if (!this.canvas.maskTool.isActive || !this.canvas.isMouseOver) {
|
||||
this.clearOverlay();
|
||||
return;
|
||||
}
|
||||
// Update overlay size if needed
|
||||
this.updateOverlaySize();
|
||||
// Clear previous cursor
|
||||
this.clearOverlay();
|
||||
// Convert world coordinates to screen coordinates
|
||||
const screenX = (worldPoint.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (worldPoint.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
// Get brush properties
|
||||
const brushRadius = (this.canvas.maskTool.brushSize / 2) * this.canvas.viewport.zoom;
|
||||
const brushStrength = this.canvas.maskTool.brushStrength;
|
||||
const brushHardness = this.canvas.maskTool.brushHardness;
|
||||
// Save context state
|
||||
this.canvas.overlayCtx.save();
|
||||
// If strength is 0, just draw outline
|
||||
if (brushStrength > 0) {
|
||||
// Draw inner fill to visualize brush effect - matches actual brush rendering
|
||||
const gradient = this.canvas.overlayCtx.createRadialGradient(screenX, screenY, 0, screenX, screenY, brushRadius);
|
||||
// Preview alpha - subtle to not obscure content
|
||||
const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
|
||||
if (brushHardness === 1) {
|
||||
// Hard brush - uniform fill within radius
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||
}
|
||||
else {
|
||||
// Soft brush - gradient fade matching actual brush
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||
if (brushHardness > 0) {
|
||||
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||
}
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||
}
|
||||
this.canvas.overlayCtx.beginPath();
|
||||
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||
this.canvas.overlayCtx.fillStyle = gradient;
|
||||
this.canvas.overlayCtx.fill();
|
||||
}
|
||||
// Draw outer circle (SIZE indicator)
|
||||
this.canvas.overlayCtx.beginPath();
|
||||
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||
// Stroke opacity based on strength (dimmer when strength is 0)
|
||||
const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
|
||||
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||
this.canvas.overlayCtx.lineWidth = 1.5;
|
||||
// Visual feedback for hardness
|
||||
if (brushHardness > 0.8) {
|
||||
// Hard brush - solid line
|
||||
this.canvas.overlayCtx.setLineDash([]);
|
||||
}
|
||||
else {
|
||||
// Soft brush - dashed line
|
||||
const dashLength = 2 + (1 - brushHardness) * 4;
|
||||
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
|
||||
}
|
||||
this.canvas.overlayCtx.stroke();
|
||||
// Center dot for small brushes
|
||||
if (brushRadius < 5) {
|
||||
this.canvas.overlayCtx.beginPath();
|
||||
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);
|
||||
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||
this.canvas.overlayCtx.fill();
|
||||
}
|
||||
// Restore context state
|
||||
this.canvas.overlayCtx.restore();
|
||||
}
|
||||
/**
|
||||
* Update overlay position when viewport changes
|
||||
*/
|
||||
updateOverlayPosition() {
|
||||
// Overlay canvas is positioned absolutely, so it doesn't need repositioning
|
||||
// Just ensure it's the right size
|
||||
this.updateOverlaySize();
|
||||
}
|
||||
}
|
||||
|
||||
172
js/CanvasView.js
172
js/CanvasView.js
@@ -17,6 +17,32 @@ async function createCanvasWidget(node, widget, app) {
|
||||
onStateChange: () => updateOutput(node, canvas)
|
||||
});
|
||||
const imageCache = new ImageCache();
|
||||
/**
|
||||
* Helper function to update the icon of a switch component.
|
||||
* @param knobIconEl The HTML element for the switch's knob icon.
|
||||
* @param isChecked The current state of the switch (e.g., checkbox.checked).
|
||||
* @param iconToolTrue The icon tool name for the 'true' state.
|
||||
* @param iconToolFalse The icon tool name for the 'false' state.
|
||||
* @param fallbackTrue The text fallback for the 'true' state.
|
||||
* @param fallbackFalse The text fallback for the 'false' state.
|
||||
*/
|
||||
const updateSwitchIcon = (knobIconEl, isChecked, iconToolTrue, iconToolFalse, fallbackTrue, fallbackFalse) => {
|
||||
if (!knobIconEl)
|
||||
return;
|
||||
const iconTool = isChecked ? iconToolTrue : iconToolFalse;
|
||||
const fallbackText = isChecked ? fallbackTrue : fallbackFalse;
|
||||
const icon = iconLoader.getIcon(iconTool);
|
||||
knobIconEl.innerHTML = ''; // Clear previous icon
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const clonedIcon = icon.cloneNode();
|
||||
clonedIcon.style.width = '20px';
|
||||
clonedIcon.style.height = '20px';
|
||||
knobIconEl.appendChild(clonedIcon);
|
||||
}
|
||||
else {
|
||||
knobIconEl.textContent = fallbackText;
|
||||
}
|
||||
};
|
||||
const helpTooltip = $el("div.painter-tooltip", {
|
||||
id: `painter-help-tooltip-${node.id}`,
|
||||
});
|
||||
@@ -72,7 +98,6 @@ async function createCanvasWidget(node, widget, app) {
|
||||
}),
|
||||
$el("button.painter-button.icon-button", {
|
||||
textContent: "?",
|
||||
title: "Show shortcuts",
|
||||
onmouseenter: (e) => {
|
||||
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
||||
showTooltip(e.target, content);
|
||||
@@ -151,34 +176,36 @@ async function createCanvasWidget(node, widget, app) {
|
||||
$el("span.switch-icon")
|
||||
])
|
||||
]);
|
||||
// Helper function to get current tooltip content based on switch state
|
||||
const getCurrentTooltipContent = () => {
|
||||
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
|
||||
return checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
||||
};
|
||||
// Helper function to update tooltip content if it's currently visible
|
||||
const updateTooltipIfVisible = () => {
|
||||
// Only update if tooltip is currently visible
|
||||
if (helpTooltip.style.display === 'block') {
|
||||
const tooltipContent = getCurrentTooltipContent();
|
||||
showTooltip(switchEl, tooltipContent);
|
||||
}
|
||||
};
|
||||
// Tooltip logic
|
||||
switchEl.addEventListener("mouseenter", (e) => {
|
||||
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
|
||||
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
||||
const tooltipContent = getCurrentTooltipContent();
|
||||
showTooltip(switchEl, tooltipContent);
|
||||
});
|
||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||
// Dynamic icon and text update on toggle
|
||||
// Dynamic icon update on toggle
|
||||
const input = switchEl.querySelector('input[type="checkbox"]');
|
||||
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
|
||||
const updateSwitchView = (isClipspace) => {
|
||||
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
|
||||
const icon = iconLoader.getIcon(iconTool);
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
knobIcon.innerHTML = '';
|
||||
const clonedIcon = icon.cloneNode();
|
||||
clonedIcon.style.width = '20px';
|
||||
clonedIcon.style.height = '20px';
|
||||
knobIcon.appendChild(clonedIcon);
|
||||
}
|
||||
else {
|
||||
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
|
||||
}
|
||||
};
|
||||
input.addEventListener('change', () => updateSwitchView(input.checked));
|
||||
input.addEventListener('change', () => {
|
||||
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
|
||||
// Update tooltip content immediately after state change
|
||||
updateTooltipIfVisible();
|
||||
});
|
||||
// Initial state
|
||||
iconLoader.preloadToolIcons().then(() => {
|
||||
updateSwitchView(isClipspace);
|
||||
updateSwitchIcon(knobIcon, isClipspace, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
|
||||
});
|
||||
return switchEl;
|
||||
})()
|
||||
@@ -293,6 +320,50 @@ async function createCanvasWidget(node, widget, app) {
|
||||
]),
|
||||
$el("div.painter-separator"),
|
||||
$el("div.painter-button-group", {}, [
|
||||
(() => {
|
||||
const switchEl = $el("label.clipboard-switch.requires-selection", {
|
||||
id: `crop-transform-switch-${node.id}`,
|
||||
title: "Toggle between Transform and Crop mode for selected layer(s)"
|
||||
}, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: false,
|
||||
onchange: (e) => {
|
||||
const isCropMode = e.target.checked;
|
||||
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||
if (selectedLayers.length === 0)
|
||||
return;
|
||||
selectedLayers.forEach((layer) => {
|
||||
layer.cropMode = isCropMode;
|
||||
if (isCropMode && !layer.cropBounds) {
|
||||
layer.cropBounds = { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||
}
|
||||
});
|
||||
canvas.saveState();
|
||||
canvas.render();
|
||||
}
|
||||
}),
|
||||
$el("span.switch-track"),
|
||||
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
|
||||
$el("span.text-clipspace", {}, ["Crop"]),
|
||||
$el("span.text-system", {}, ["Transform"])
|
||||
]),
|
||||
$el("span.switch-knob", {}, [
|
||||
$el("span.switch-icon", { id: `crop-transform-icon-${node.id}` })
|
||||
])
|
||||
]);
|
||||
const input = switchEl.querySelector('input[type="checkbox"]');
|
||||
const knobIcon = switchEl.querySelector('.switch-icon');
|
||||
input.addEventListener('change', () => {
|
||||
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
|
||||
});
|
||||
// Initial state
|
||||
iconLoader.preloadToolIcons().then(() => {
|
||||
updateSwitchIcon(knobIcon, false, // Initial state is transform
|
||||
LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
|
||||
});
|
||||
return switchEl;
|
||||
})(),
|
||||
$el("button.painter-button.requires-selection", {
|
||||
textContent: "Rotate +90°",
|
||||
title: "Rotate selected layer(s) by +90 degrees",
|
||||
@@ -359,6 +430,8 @@ async function createCanvasWidget(node, widget, app) {
|
||||
delete newLayer.imageId;
|
||||
canvas.layers[selectedLayerIndex] = newLayer;
|
||||
canvas.canvasSelection.updateSelection([newLayer]);
|
||||
// Invalidate processed image cache when layer image changes (matting)
|
||||
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
|
||||
canvas.render();
|
||||
canvas.saveState();
|
||||
showSuccessNotification("Background removed successfully!");
|
||||
@@ -395,7 +468,8 @@ async function createCanvasWidget(node, widget, app) {
|
||||
$el("div.painter-button-group", { id: "mask-controls" }, [
|
||||
$el("label.clipboard-switch.mask-switch", {
|
||||
id: `toggle-mask-switch-${node.id}`,
|
||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
|
||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" },
|
||||
title: "Toggle mask overlay visibility on canvas (mask still affects output when disabled)"
|
||||
}, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
@@ -480,6 +554,25 @@ async function createCanvasWidget(node, widget, app) {
|
||||
setTimeout(() => canvas.render(), 0);
|
||||
}
|
||||
}),
|
||||
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
||||
$el("label", { for: "preview-opacity-slider", textContent: "Mask Opacity:" }),
|
||||
$el("input", {
|
||||
id: "preview-opacity-slider",
|
||||
type: "range",
|
||||
min: "0",
|
||||
max: "1",
|
||||
step: "0.05",
|
||||
value: "0.5",
|
||||
oninput: (e) => {
|
||||
const value = e.target.value;
|
||||
canvas.maskTool.setPreviewOpacity(parseFloat(value));
|
||||
const valueEl = document.getElementById('preview-opacity-value');
|
||||
if (valueEl)
|
||||
valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||
}
|
||||
}),
|
||||
$el("div.slider-value", { id: "preview-opacity-value" }, ["50%"])
|
||||
]),
|
||||
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
||||
$el("label", { for: "brush-size-slider", textContent: "Size:" }),
|
||||
$el("input", {
|
||||
@@ -629,19 +722,38 @@ async function createCanvasWidget(node, widget, app) {
|
||||
const updateButtonStates = () => {
|
||||
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
||||
const hasSelection = selectionCount > 0;
|
||||
controlPanel.querySelectorAll('.requires-selection').forEach((btn) => {
|
||||
const button = btn;
|
||||
if (button.textContent === 'Fuse') {
|
||||
button.disabled = selectionCount < 2;
|
||||
}
|
||||
else {
|
||||
button.disabled = !hasSelection;
|
||||
// --- Handle Standard Buttons ---
|
||||
controlPanel.querySelectorAll('.requires-selection').forEach((el) => {
|
||||
if (el.tagName === 'BUTTON') {
|
||||
if (el.textContent === 'Fuse') {
|
||||
el.disabled = selectionCount < 2;
|
||||
}
|
||||
else {
|
||||
el.disabled = !hasSelection;
|
||||
}
|
||||
}
|
||||
});
|
||||
const mattingBtn = controlPanel.querySelector('.matting-button');
|
||||
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
||||
mattingBtn.disabled = selectionCount !== 1;
|
||||
}
|
||||
// --- Handle Crop/Transform Switch ---
|
||||
const switchEl = controlPanel.querySelector(`#crop-transform-switch-${node.id}`);
|
||||
if (switchEl) {
|
||||
const input = switchEl.querySelector('input');
|
||||
const knobIcon = switchEl.querySelector('.switch-icon');
|
||||
const isDisabled = !hasSelection;
|
||||
switchEl.classList.toggle('disabled', isDisabled);
|
||||
input.disabled = isDisabled;
|
||||
if (!isDisabled) {
|
||||
const isCropMode = canvas.canvasSelection.selectedLayers[0].cropMode || false;
|
||||
if (input.checked !== isCropMode) {
|
||||
input.checked = isCropMode;
|
||||
}
|
||||
// Update icon view
|
||||
updateSwitchIcon(knobIcon, isCropMode, LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
|
||||
}
|
||||
}
|
||||
};
|
||||
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
||||
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
|
||||
@@ -921,7 +1033,7 @@ async function createCanvasWidget(node, widget, app) {
|
||||
}
|
||||
const canvasNodeInstances = new Map();
|
||||
app.registerExtension({
|
||||
name: "Comfy.CanvasNode",
|
||||
name: "Comfy.LayerForgeNode",
|
||||
init() {
|
||||
addStylesheet(getUrl('./css/canvas_view.css'));
|
||||
const originalQueuePrompt = app.queuePrompt;
|
||||
@@ -955,7 +1067,7 @@ app.registerExtension({
|
||||
};
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeType.comfyClass === "CanvasNode") {
|
||||
if (nodeType.comfyClass === "LayerForgeNode") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||
|
||||
184
js/MaskTool.js
184
js/MaskTool.js
@@ -3,11 +3,15 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('Mask_tool');
|
||||
export class MaskTool {
|
||||
constructor(canvasInstance, callbacks = {}) {
|
||||
// Track strokes during drawing for efficient overlay updates
|
||||
this.currentStrokePoints = [];
|
||||
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
|
||||
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
|
||||
this.canvasInstance = canvasInstance;
|
||||
this.mainCanvas = canvasInstance.canvas;
|
||||
this.onStateChange = callbacks.onStateChange || null;
|
||||
// Initialize stroke tracking for overlay drawing
|
||||
this.currentStrokePoints = [];
|
||||
// Initialize chunked mask system
|
||||
this.maskChunks = new Map();
|
||||
this.chunkSize = 512;
|
||||
@@ -28,8 +32,9 @@ export class MaskTool {
|
||||
this.isOverlayVisible = true;
|
||||
this.isActive = false;
|
||||
this.brushSize = 20;
|
||||
this.brushStrength = 0.5;
|
||||
this.brushHardness = 0.5;
|
||||
this._brushStrength = 0.5;
|
||||
this._brushHardness = 0.5;
|
||||
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||
this.isDrawing = false;
|
||||
this.lastPosition = null;
|
||||
const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
|
||||
@@ -79,8 +84,27 @@ export class MaskTool {
|
||||
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
|
||||
}
|
||||
}
|
||||
// Getters for brush properties
|
||||
get brushStrength() {
|
||||
return this._brushStrength;
|
||||
}
|
||||
get brushHardness() {
|
||||
return this._brushHardness;
|
||||
}
|
||||
get previewOpacity() {
|
||||
return this._previewOpacity;
|
||||
}
|
||||
setBrushHardness(hardness) {
|
||||
this.brushHardness = Math.max(0, Math.min(1, hardness));
|
||||
this._brushHardness = Math.max(0, Math.min(1, hardness));
|
||||
}
|
||||
setPreviewOpacity(opacity) {
|
||||
this._previewOpacity = Math.max(0, Math.min(1, opacity));
|
||||
// Update the stroke overlay canvas opacity when preview opacity changes
|
||||
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
|
||||
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
|
||||
}
|
||||
// Trigger canvas render to update mask display opacity
|
||||
this.canvasInstance.render();
|
||||
}
|
||||
initMaskCanvas() {
|
||||
// Initialize chunked system
|
||||
@@ -671,16 +695,17 @@ export class MaskTool {
|
||||
this.brushSize = Math.max(1, size);
|
||||
}
|
||||
setBrushStrength(strength) {
|
||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
||||
this._brushStrength = Math.max(0, Math.min(1, strength));
|
||||
}
|
||||
handleMouseDown(worldCoords, viewCoords) {
|
||||
if (!this.isActive)
|
||||
return;
|
||||
this.isDrawing = true;
|
||||
this.lastPosition = worldCoords;
|
||||
// Activate chunks around the drawing position for performance
|
||||
this.updateActiveChunksForDrawing(worldCoords);
|
||||
this.draw(worldCoords);
|
||||
// Initialize stroke tracking for live preview
|
||||
this.currentStrokePoints = [worldCoords];
|
||||
// Clear any previous stroke overlay
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
this.clearPreview();
|
||||
}
|
||||
handleMouseMove(worldCoords, viewCoords) {
|
||||
@@ -689,14 +714,69 @@ export class MaskTool {
|
||||
}
|
||||
if (!this.isActive || !this.isDrawing)
|
||||
return;
|
||||
// Dynamically update active chunks as user moves while drawing
|
||||
this.updateActiveChunksForDrawing(worldCoords);
|
||||
this.draw(worldCoords);
|
||||
// Add point to stroke tracking
|
||||
this.currentStrokePoints.push(worldCoords);
|
||||
// Draw interpolated segments for smooth strokes without gaps
|
||||
if (this.lastPosition) {
|
||||
// Calculate distance between last and current position
|
||||
const dx = worldCoords.x - this.lastPosition.x;
|
||||
const dy = worldCoords.y - this.lastPosition.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
// If distance is small, just draw a single segment
|
||||
if (distance < this.brushSize / 4) {
|
||||
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
|
||||
}
|
||||
else {
|
||||
// Interpolate points for smooth drawing without gaps
|
||||
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
|
||||
// Draw all interpolated segments
|
||||
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
|
||||
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(interpolatedPoints[i], interpolatedPoints[i + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.lastPosition = worldCoords;
|
||||
}
|
||||
/**
|
||||
* Interpolates points between two positions to create smooth strokes without gaps
|
||||
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
|
||||
*/
|
||||
interpolatePoints(start, end, distance) {
|
||||
const points = [];
|
||||
// Calculate number of interpolated points based on brush size
|
||||
// More points = smoother line
|
||||
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
|
||||
const numSteps = Math.ceil(distance / stepSize);
|
||||
// Always include start point
|
||||
points.push(start);
|
||||
// Interpolate intermediate points
|
||||
for (let i = 1; i < numSteps; i++) {
|
||||
const t = i / numSteps;
|
||||
points.push({
|
||||
x: start.x + (end.x - start.x) * t,
|
||||
y: start.y + (end.y - start.y) * t
|
||||
});
|
||||
}
|
||||
// Always include end point
|
||||
points.push(end);
|
||||
return points;
|
||||
}
|
||||
/**
|
||||
* Called when viewport changes during drawing to update stroke overlay
|
||||
* This ensures the stroke preview scales correctly with zoom changes
|
||||
*/
|
||||
handleViewportChange() {
|
||||
if (this.isDrawing && this.currentStrokePoints.length > 1) {
|
||||
// Redraw the entire stroke overlay with new viewport settings
|
||||
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
|
||||
}
|
||||
}
|
||||
handleMouseLeave() {
|
||||
this.previewVisible = false;
|
||||
this.clearPreview();
|
||||
// Clear overlay canvases when mouse leaves
|
||||
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
}
|
||||
handleMouseEnter() {
|
||||
this.previewVisible = true;
|
||||
@@ -706,10 +786,15 @@ export class MaskTool {
|
||||
return;
|
||||
if (this.isDrawing) {
|
||||
this.isDrawing = false;
|
||||
// Commit the stroke from overlay to actual mask chunks
|
||||
this.commitStrokeToChunks();
|
||||
// Clear stroke overlay and reset state
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
this.currentStrokePoints = [];
|
||||
this.lastPosition = null;
|
||||
this.currentDrawingChunk = null;
|
||||
// After drawing is complete, update active canvas to show all chunks
|
||||
this.updateActiveMaskCanvas(true); // forceShowAll = true
|
||||
this.updateActiveMaskCanvas(true); // Force full update
|
||||
this.completeMaskOperation();
|
||||
this.drawBrushPreview(viewCoords);
|
||||
}
|
||||
@@ -724,6 +809,38 @@ export class MaskTool {
|
||||
// This prevents unnecessary recomposition during drawing
|
||||
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
||||
}
|
||||
/**
|
||||
* Commits the current stroke from overlay to actual mask chunks
|
||||
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
|
||||
*/
|
||||
commitStrokeToChunks() {
|
||||
if (this.currentStrokePoints.length < 2) {
|
||||
return; // Need at least 2 points for a stroke
|
||||
}
|
||||
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
|
||||
// Replay the entire stroke path with interpolation for smooth, accurate lines
|
||||
for (let i = 1; i < this.currentStrokePoints.length; i++) {
|
||||
const startPoint = this.currentStrokePoints[i - 1];
|
||||
const endPoint = this.currentStrokePoints[i];
|
||||
// Calculate distance between points
|
||||
const dx = endPoint.x - startPoint.x;
|
||||
const dy = endPoint.y - startPoint.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance < this.brushSize / 4) {
|
||||
// Small distance - draw single segment
|
||||
this.drawOnChunks(startPoint, endPoint);
|
||||
}
|
||||
else {
|
||||
// Large distance - interpolate for smooth line without gaps
|
||||
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
|
||||
// Draw all interpolated segments
|
||||
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
|
||||
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.debug("Stroke committed to chunks successfully with interpolation");
|
||||
}
|
||||
/**
|
||||
* Draws a line between two world coordinates on the appropriate chunks
|
||||
*/
|
||||
@@ -767,13 +884,13 @@ export class MaskTool {
|
||||
chunk.ctx.moveTo(startLocal.x, startLocal.y);
|
||||
chunk.ctx.lineTo(endLocal.x, endLocal.y);
|
||||
const gradientRadius = this.brushSize / 2;
|
||||
if (this.brushHardness === 1) {
|
||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
||||
if (this._brushHardness === 1) {
|
||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||
}
|
||||
else {
|
||||
const innerRadius = gradientRadius * this.brushHardness;
|
||||
const innerRadius = gradientRadius * this._brushHardness;
|
||||
const gradient = chunk.ctx.createRadialGradient(endLocal.x, endLocal.y, innerRadius, endLocal.x, endLocal.y, gradientRadius);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this._brushStrength})`);
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||
chunk.ctx.strokeStyle = gradient;
|
||||
}
|
||||
@@ -805,28 +922,17 @@ export class MaskTool {
|
||||
return true; // For now, always draw - more precise intersection can be added later
|
||||
}
|
||||
/**
|
||||
* Updates active canvas when drawing affects chunks with throttling to prevent lag
|
||||
* During drawing, only updates the affected active chunks for performance
|
||||
* Updates active canvas when drawing affects chunks
|
||||
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||
*/
|
||||
updateActiveCanvasIfNeeded(startWorld, endWorld) {
|
||||
// Calculate which chunks were affected by this drawing operation
|
||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
||||
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
|
||||
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
|
||||
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
|
||||
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
|
||||
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
|
||||
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
|
||||
// During drawing, only update affected chunks that are active for performance
|
||||
if (this.isDrawing) {
|
||||
// Use throttled partial update for active chunks only
|
||||
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
|
||||
}
|
||||
else {
|
||||
// This method is now simplified - we only update after drawing is complete
|
||||
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||
if (!this.isDrawing) {
|
||||
// Not drawing - do full update to show all chunks
|
||||
this.updateActiveMaskCanvas(true);
|
||||
}
|
||||
// During drawing, we don't update chunks at all - overlay handles preview
|
||||
}
|
||||
/**
|
||||
* Schedules a throttled update of the active mask canvas to prevent excessive redraws
|
||||
@@ -903,18 +1009,12 @@ export class MaskTool {
|
||||
}
|
||||
drawBrushPreview(viewCoords) {
|
||||
if (!this.previewVisible || this.isDrawing) {
|
||||
this.clearPreview();
|
||||
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||
return;
|
||||
}
|
||||
this.clearPreview();
|
||||
const zoom = this.canvasInstance.viewport.zoom;
|
||||
const radius = (this.brushSize / 2) * zoom;
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
|
||||
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
this.previewCtx.lineWidth = 1;
|
||||
this.previewCtx.setLineDash([2, 4]);
|
||||
this.previewCtx.stroke();
|
||||
// Use overlay canvas instead of preview canvas for brush cursor
|
||||
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||
}
|
||||
clearPreview() {
|
||||
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.j
|
||||
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
||||
import { convertToImage } from "./utils/ImageUtils.js";
|
||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
|
||||
const log = createModuleLogger('SAMDetectorIntegration');
|
||||
/**
|
||||
* SAM Detector Integration for LayerForge
|
||||
@@ -324,6 +325,8 @@ async function handleSAMDetectorResult(node, resultImage) {
|
||||
node.samOriginalImgSrc = null;
|
||||
}
|
||||
}
|
||||
// Store original onClipspaceEditorSave function to restore later
|
||||
let originalOnClipspaceEditorSave = null;
|
||||
// Function to setup SAM Detector hook in menu options
|
||||
export function setupSAMDetectorHook(node, options) {
|
||||
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||
@@ -347,8 +350,39 @@ export function setupSAMDetectorHook(node, options) {
|
||||
// Set the image to the node for clipspace
|
||||
node.imgs = [uploadResult.imageElement];
|
||||
node.clipspaceImg = uploadResult.imageElement;
|
||||
// Ensure proper clipspace structure for updated ComfyUI
|
||||
if (!ComfyApp.clipspace) {
|
||||
ComfyApp.clipspace = {};
|
||||
}
|
||||
// Set up clipspace with proper indices
|
||||
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
|
||||
ComfyApp.clipspace.selectedIndex = 0;
|
||||
ComfyApp.clipspace.combinedIndex = 0;
|
||||
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||
// Copy to ComfyUI clipspace
|
||||
ComfyApp.copyToClipspace(node);
|
||||
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
|
||||
if (!originalOnClipspaceEditorSave) {
|
||||
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
|
||||
ComfyApp.onClipspaceEditorSave = function () {
|
||||
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
|
||||
// Use the unified clipspace validation function
|
||||
const isValid = validateAndFixClipspace();
|
||||
if (!isValid) {
|
||||
log.error("Clipspace validation failed, cannot proceed with paste");
|
||||
return;
|
||||
}
|
||||
// Call the original function
|
||||
if (originalOnClipspaceEditorSave) {
|
||||
originalOnClipspaceEditorSave.call(ComfyApp);
|
||||
}
|
||||
// Restore the original function after use
|
||||
if (originalOnClipspaceEditorSave) {
|
||||
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
|
||||
originalOnClipspaceEditorSave = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
// Start monitoring for SAM Detector results
|
||||
startSAMDetectorMonitoring(node);
|
||||
log.info("Canvas automatically sent to clipspace and monitoring started");
|
||||
|
||||
170
js/css/blend_mode_menu.css
Normal file
170
js/css/blend_mode_menu.css
Normal file
@@ -0,0 +1,170 @@
|
||||
/* Blend Mode Menu Styles */
|
||||
#blend-mode-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-title-bar {
|
||||
background: #3a3a3a;
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 3px 3px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-title-text {
|
||||
flex: 1;
|
||||
cursor: move;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-content {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-container {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-label {
|
||||
color: white;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-container {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option {
|
||||
padding: 5px 10px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option.active {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: none;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
margin: 2px;
|
||||
@@ -51,6 +51,32 @@
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
/* Crop mode button styling */
|
||||
.painter-button#crop-mode-btn {
|
||||
background-color: #444;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn.primary {
|
||||
background-color: #0080ff;
|
||||
border-color: #0070e0;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn.primary:hover {
|
||||
background-color: #1090ff;
|
||||
border-color: #0080ff;
|
||||
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn:hover {
|
||||
background-color: #555;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.painter-button.success {
|
||||
border-color: #4ae27a;
|
||||
background-color: #444;
|
||||
@@ -187,7 +213,7 @@
|
||||
border-radius: 5px;
|
||||
border: 1px solid #555;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
@@ -306,6 +332,25 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Disabled state for switch */
|
||||
.clipboard-switch.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
background: #3a3a3a !important; /* Override gradient */
|
||||
border-color: #4a4a4a !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.clipboard-switch.disabled .switch-knob {
|
||||
background-color: #4a4a4a !important;
|
||||
}
|
||||
|
||||
.clipboard-switch.disabled .switch-labels {
|
||||
color: #777 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.painter-separator {
|
||||
width: 1px;
|
||||
|
||||
230
js/css/layers_panel.css
Normal file
230
js/css/layers_panel.css
Normal file
@@ -0,0 +1,230 @@
|
||||
/* Layers Panel Styles */
|
||||
.layers-panel {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layers-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.layers-panel-title {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layers-panel-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layers-btn {
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.layers-btn:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.layers-btn:active {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layers-btn:disabled {
|
||||
background: #2a2a2a;
|
||||
color: #666666;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.layers-btn:disabled:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 4px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.layer-row.selected {
|
||||
background: #2d5aa0 !important;
|
||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||
}
|
||||
|
||||
.layer-row.dragging {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.layer-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layer-thumbnail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name.editing {
|
||||
background: #4a4a4a;
|
||||
border: 1px solid #6a6a6a;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drag-insertion-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #4a7bc8;
|
||||
border-radius: 1px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Icon container styles */
|
||||
.layers-panel .icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.visibility-hidden {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.visibility-hidden img {
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.fallback-text {
|
||||
font-size: 10px;
|
||||
color: #888888;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||
// @ts-ignore
|
||||
import { api } from "../../../scripts/api.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../../scripts/app.js";
|
||||
const log = createModuleLogger('ClipboardManager');
|
||||
export class ClipboardManager {
|
||||
constructor(canvas) {
|
||||
@@ -39,7 +38,12 @@ export class ClipboardManager {
|
||||
*/
|
||||
this.tryClipspacePaste = withErrorHandling(async (addMode) => {
|
||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||
ComfyApp.pasteFromClipspace(this.canvas.node);
|
||||
// Use the unified clipspace validation and paste function
|
||||
const pasteSuccess = safeClipspacePaste(this.canvas.node);
|
||||
if (!pasteSuccess) {
|
||||
log.debug("Safe clipspace paste failed");
|
||||
return false;
|
||||
}
|
||||
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||
const clipspaceImage = this.canvas.node.imgs[0];
|
||||
if (clipspaceImage && clipspaceImage.src) {
|
||||
|
||||
99
js/utils/ClipspaceUtils.js
Normal file
99
js/utils/ClipspaceUtils.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../../scripts/app.js";
|
||||
const log = createModuleLogger('ClipspaceUtils');
|
||||
/**
|
||||
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
|
||||
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
|
||||
*/
|
||||
export function validateAndFixClipspace() {
|
||||
log.debug("Validating and fixing clipspace structure");
|
||||
// Check if clipspace exists
|
||||
if (!ComfyApp.clipspace) {
|
||||
log.debug("ComfyUI clipspace is not available");
|
||||
return false;
|
||||
}
|
||||
// Validate clipspace structure
|
||||
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
|
||||
log.debug("ComfyUI clipspace has no images");
|
||||
return false;
|
||||
}
|
||||
log.debug("Current clipspace state:", {
|
||||
hasImgs: !!ComfyApp.clipspace.imgs,
|
||||
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||
img_paste_mode: ComfyApp.clipspace.img_paste_mode
|
||||
});
|
||||
// Ensure required indices are set
|
||||
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
|
||||
ComfyApp.clipspace.selectedIndex = 0;
|
||||
log.debug("Fixed clipspace selectedIndex to 0");
|
||||
}
|
||||
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
|
||||
ComfyApp.clipspace.combinedIndex = 0;
|
||||
log.debug("Fixed clipspace combinedIndex to 0");
|
||||
}
|
||||
if (!ComfyApp.clipspace.img_paste_mode) {
|
||||
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||
log.debug("Fixed clipspace img_paste_mode to 'selected'");
|
||||
}
|
||||
// Ensure indices are within bounds
|
||||
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
|
||||
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
|
||||
ComfyApp.clipspace.selectedIndex = maxIndex;
|
||||
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
|
||||
}
|
||||
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
|
||||
ComfyApp.clipspace.combinedIndex = maxIndex;
|
||||
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
|
||||
}
|
||||
// Verify the image at combinedIndex exists and has src
|
||||
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||
if (!combinedImg || !combinedImg.src) {
|
||||
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
|
||||
// Try to use the first available image
|
||||
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
|
||||
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
|
||||
ComfyApp.clipspace.combinedIndex = i;
|
||||
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Final check - if still no valid image found
|
||||
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||
if (!finalImg || !finalImg.src) {
|
||||
log.error("No valid images found in clipspace after attempting fixes");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
log.debug("Final clipspace structure:", {
|
||||
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
|
||||
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
|
||||
});
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
|
||||
* @param {any} node - The ComfyUI node to paste to
|
||||
* @returns {boolean} - True if paste was successful, false otherwise
|
||||
*/
|
||||
export function safeClipspacePaste(node) {
|
||||
log.debug("Attempting safe clipspace paste");
|
||||
if (!validateAndFixClipspace()) {
|
||||
log.debug("Clipspace validation failed, cannot paste");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
ComfyApp.pasteFromClipspace(node);
|
||||
log.debug("Successfully called pasteFromClipspace");
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error calling pasteFromClipspace:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,19 @@ export const LAYERFORGE_TOOLS = {
|
||||
SETTINGS: 'settings',
|
||||
SYSTEM_CLIPBOARD: 'system_clipboard',
|
||||
CLIPSPACE: 'clipspace',
|
||||
CROP: 'crop',
|
||||
TRANSFORM: 'transform',
|
||||
};
|
||||
// SVG Icons for LayerForge tools
|
||||
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
|
||||
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
|
||||
const CROP_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 15h3V7c0-1.1-.9-2-2-2H10v3h7v7zM7 18V1H4v4H0v3h4v10c0 2 1 3 3 3h10v4h3v-4h4v-3H24z"/></svg>';
|
||||
const TRANSFORM_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M11.3 17.096c.092-.044.34-.052 1.028-.044l.912.008.124.124c.184.184.184.408.004.584l-.128.132-.896.012c-.72.008-.924 0-1.036-.048-.18-.072-.284-.264-.256-.452.028-.168.092-.248.248-.316Zm-3.164 0c.096-.044.328-.052 1.036-.044l.916.008.116.132c.16.18.16.396 0 .576l-.116.132-.876.012c-.552.008-.928-.004-1.02-.032-.388-.112-.428-.62-.056-.784Zm-4.6-1.168.112-.096 1.42.004 1.424.004.116.116.116.116V17.48v1.408l-.116.116-.116.116H5.068h-1.42l-.112-.096-.112-.096L3.42 17.48V16.032l.112-.096ZM4.78 12.336c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.964.964l-.116.128c-.1.112-.144.132-.304.132s-.204-.02-.304-.132L4.644 14.4l-.004-.964v-.964l.136-.136Zm8.868-.648c-.008-.024-.004-.048.008-.048s1.504.512 3.312 1.136c1.812.624 4.252 1.464 5.424 1.868 1.168.404 2.128.744 2.128.76 0 .012-.24.108-.528.212-.292.104-1.468.52-2.616.928l-2.08.74-.936 2.62c-.512 1.44-.944 2.616-.956 2.616-.016 0-.86-2.424-1.88-5.392-1.02-2.964-1.864-5.412-1.876-5.44ZM19.292 9.08c.216-.088.432-.02.548.168.076.124.08.188.072 1.06l-.012.928-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12-.012-.928c-.008-.872-.004-.936.072-1.06.044-.072.12-.148.172-.168Zm-14.516.096c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.956c0 1.064-.004 1.088-.268 1.2-.18.072-.376.012-.492-.148-.076-.104-.08-.172-.08-1.06V9.312l.136-.136ZM19.192 6c.096-.088.168-.116.288-.116s.192.028.288.116l.132.116V7.1v.98l-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12V7.096 6.112l.132-.116ZM4.816 5.964c.048-.044.152-.072.256-.072.144 0 .196.02.292.124l.116.124v.98.968l-.116.116c-.092.092-.152.116-.284.116-.408 0-.44-.28-.44-1.22s.012-1.016.176-1.148Zm9.516-3.192.14-.136.968.004h.968l.112.116c.152.152.188.3.108.468-.124.252-.196.276-1.044.288-.42.008-.84.004-.936-.012-.24-.036-.38-.192-.436-.408-.02-.156-.008-.184.12-.312Zm-3.156-.268.136.136h.956c1.064 0 1.088.004 1.2.268.072.172.016.372-.136.492-.096.076-.16.08-1.06.08h-.96l-.136-.136c-.104-.104-.136-.168-.136-.284s.032-.18.136-.284Zm-3.16 0 .136.136h.96c.94 0 .964.004 1.068.088.2.176.196.508-.004.668-.1.08-.156.084-1.064.084h-.96l-.136-.136c-.188-.188-.188-.38 0-.568Zm10.04-1.14c.044-.02.712-.032 1.476-.028l1.396.008.096.112.096.112v1.424 1.5l-.116.116-.116.116L19.48 4.72H18.072l-.116-.116-.116-.116V3.072c0-1.524.004-1.544.216-1.632ZM3.62 1.456c.184-.08 2.74-.08 2.896 0 .196.104.204.164.204 1.604s-.008 1.5-.204 1.604c-.148.076-2.732.084-2.896.008-.212-.096-.22-.148-.22-1.608s.008-1.516.22-1.608Z"/></svg>';
|
||||
const LAYERFORGE_TOOL_ICONS = {
|
||||
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.CROP]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CROP_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.TRANSFORM]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRANSFORM_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
|
||||
@@ -54,7 +60,9 @@ const LAYERFORGE_TOOL_COLORS = {
|
||||
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
||||
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
||||
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
|
||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
|
||||
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
|
||||
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
|
||||
};
|
||||
export class IconLoader {
|
||||
constructor() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "layerforge"
|
||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||
version = "1.5.0"
|
||||
version = "1.5.5"
|
||||
license = { text = "MIT License" }
|
||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||
|
||||
|
||||
@@ -166,10 +166,14 @@ export class BatchPreviewManager {
|
||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||
if (this.maskWasVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.remove('primary');
|
||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
|
||||
const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
|
||||
if (toggleSwitch) {
|
||||
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
toggleSwitch.classList.remove('primary');
|
||||
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '0.5';
|
||||
}
|
||||
@@ -218,10 +222,14 @@ export class BatchPreviewManager {
|
||||
|
||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.add('primary');
|
||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
|
||||
const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
|
||||
if (toggleSwitch) {
|
||||
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
toggleSwitch.classList.add('primary');
|
||||
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '1';
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ export class Canvas {
|
||||
node: ComfyNode;
|
||||
offscreenCanvas: HTMLCanvasElement;
|
||||
offscreenCtx: CanvasRenderingContext2D | null;
|
||||
overlayCanvas: HTMLCanvasElement;
|
||||
overlayCtx: CanvasRenderingContext2D;
|
||||
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
||||
onViewportChange: (() => void) | null;
|
||||
onStateChange: (() => void) | undefined;
|
||||
@@ -122,6 +124,16 @@ export class Canvas {
|
||||
});
|
||||
this.offscreenCanvas = offscreenCanvas;
|
||||
this.offscreenCtx = offscreenCtx;
|
||||
|
||||
// Create overlay canvas for brush cursor and other lightweight overlays
|
||||
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
|
||||
alpha: true,
|
||||
willReadFrequently: false
|
||||
});
|
||||
if (!overlayCtx) throw new Error("Could not create overlay canvas context");
|
||||
this.overlayCanvas = overlayCanvas;
|
||||
this.overlayCtx = overlayCtx;
|
||||
|
||||
this.canvasContainer = null;
|
||||
|
||||
this.dataInitialized = false;
|
||||
|
||||
@@ -269,7 +269,12 @@ export class CanvasIO {
|
||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||
|
||||
|
||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
|
||||
throw new Error(
|
||||
`Failed to get confirmation from server for node ${nodeId}. ` +
|
||||
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
|
||||
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
|
||||
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,36 @@ interface MouseCoordinates {
|
||||
view: Point;
|
||||
}
|
||||
|
||||
interface ModifierState {
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
interface TransformOrigin {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
cropBounds?: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
interface InteractionState {
|
||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape';
|
||||
panStart: Point;
|
||||
dragStart: Point;
|
||||
transformOrigin: Partial<Layer> & { centerX?: number, centerY?: number };
|
||||
transformOrigin: TransformOrigin | null;
|
||||
resizeHandle: string | null;
|
||||
resizeAnchor: Point;
|
||||
canvasResizeStart: Point;
|
||||
isCtrlPressed: boolean;
|
||||
isMetaPressed: boolean;
|
||||
isAltPressed: boolean;
|
||||
isShiftPressed: boolean;
|
||||
isSPressed: boolean;
|
||||
@@ -35,17 +56,35 @@ export class CanvasInteractions {
|
||||
public interaction: InteractionState;
|
||||
private originalLayerPositions: Map<Layer, Point>;
|
||||
|
||||
// Bound event handlers to enable proper removeEventListener and avoid leaks
|
||||
private onMouseDown = (e: MouseEvent) => this.handleMouseDown(e);
|
||||
private onMouseMove = (e: MouseEvent) => this.handleMouseMove(e);
|
||||
private onMouseUp = (e: MouseEvent) => this.handleMouseUp(e);
|
||||
private onMouseEnter = (e: MouseEvent) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); };
|
||||
private onMouseLeave = (e: MouseEvent) => { this.canvas.isMouseOver = false; this.handleMouseLeave(e); };
|
||||
private onWheel = (e: WheelEvent) => this.handleWheel(e);
|
||||
private onKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e);
|
||||
private onKeyUp = (e: KeyboardEvent) => this.handleKeyUp(e);
|
||||
private onDragOver = (e: DragEvent) => this.handleDragOver(e);
|
||||
private onDragEnter = (e: DragEvent) => this.handleDragEnter(e);
|
||||
private onDragLeave = (e: DragEvent) => this.handleDragLeave(e);
|
||||
private onDrop = (e: DragEvent) => { this.handleDrop(e); };
|
||||
private onContextMenu = (e: MouseEvent) => this.handleContextMenu(e);
|
||||
private onBlur = () => this.handleBlur();
|
||||
private onPaste = (e: ClipboardEvent) => this.handlePasteEvent(e);
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.interaction = {
|
||||
mode: 'none',
|
||||
panStart: { x: 0, y: 0 },
|
||||
dragStart: { x: 0, y: 0 },
|
||||
transformOrigin: {},
|
||||
transformOrigin: null,
|
||||
resizeHandle: null,
|
||||
resizeAnchor: { x: 0, y: 0 },
|
||||
canvasResizeStart: { x: 0, y: 0 },
|
||||
isCtrlPressed: false,
|
||||
isMetaPressed: false,
|
||||
isAltPressed: false,
|
||||
isShiftPressed: false,
|
||||
isSPressed: false,
|
||||
@@ -68,13 +107,21 @@ export class CanvasInteractions {
|
||||
};
|
||||
}
|
||||
|
||||
private getModifierState(e?: MouseEvent | WheelEvent | KeyboardEvent): ModifierState {
|
||||
return {
|
||||
ctrl: this.interaction.isCtrlPressed || (e as any)?.ctrlKey || false,
|
||||
shift: this.interaction.isShiftPressed || (e as any)?.shiftKey || false,
|
||||
alt: this.interaction.isAltPressed || (e as any)?.altKey || false,
|
||||
meta: this.interaction.isMetaPressed || (e as any)?.metaKey || false,
|
||||
};
|
||||
}
|
||||
|
||||
private preventEventDefaults(e: Event): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private performZoomOperation(worldCoords: Point, zoomFactor: number): void {
|
||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
@@ -84,6 +131,11 @@ export class CanvasInteractions {
|
||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
|
||||
|
||||
// Update stroke overlay if mask tool is drawing during zoom
|
||||
if (this.canvas.maskTool.isDrawing) {
|
||||
this.canvas.maskTool.handleViewportChange();
|
||||
}
|
||||
|
||||
this.canvas.onViewportChange?.();
|
||||
}
|
||||
|
||||
@@ -106,34 +158,49 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
setupEventListeners(): void {
|
||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown as EventListener);
|
||||
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp as EventListener);
|
||||
this.canvas.canvas.addEventListener('wheel', this.onWheel as EventListener, { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.onKeyDown as EventListener);
|
||||
this.canvas.canvas.addEventListener('keyup', this.onKeyUp as EventListener);
|
||||
|
||||
// Add a blur event listener to the window to reset key states
|
||||
window.addEventListener('blur', this.handleBlur.bind(this));
|
||||
window.addEventListener('blur', this.onBlur);
|
||||
|
||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||
document.addEventListener('paste', this.onPaste as unknown as EventListener);
|
||||
|
||||
this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => {
|
||||
this.canvas.isMouseOver = true;
|
||||
this.handleMouseEnter(e);
|
||||
});
|
||||
this.canvas.canvas.addEventListener('mouseleave', (e: MouseEvent) => {
|
||||
this.canvas.isMouseOver = false;
|
||||
this.handleMouseLeave(e);
|
||||
});
|
||||
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
|
||||
|
||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this) as unknown as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragover', this.onDragOver as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave as EventListener);
|
||||
this.canvas.canvas.addEventListener('drop', this.onDrop as unknown as EventListener);
|
||||
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu as EventListener);
|
||||
}
|
||||
|
||||
teardownEventListeners(): void {
|
||||
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown as EventListener);
|
||||
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove as EventListener);
|
||||
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp as EventListener);
|
||||
this.canvas.canvas.removeEventListener('wheel', this.onWheel as EventListener);
|
||||
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
|
||||
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener);
|
||||
|
||||
window.removeEventListener('blur', this.onBlur);
|
||||
document.removeEventListener('paste', this.onPaste as unknown as EventListener);
|
||||
|
||||
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter as EventListener);
|
||||
this.canvas.canvas.removeEventListener('mouseleave', this.onMouseLeave as EventListener);
|
||||
|
||||
this.canvas.canvas.removeEventListener('dragover', this.onDragOver as EventListener);
|
||||
this.canvas.canvas.removeEventListener('dragenter', this.onDragEnter as EventListener);
|
||||
this.canvas.canvas.removeEventListener('dragleave', this.onDragLeave as EventListener);
|
||||
this.canvas.canvas.removeEventListener('drop', this.onDrop as unknown as EventListener);
|
||||
|
||||
this.canvas.canvas.removeEventListener('contextmenu', this.onContextMenu as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,10 +244,11 @@ export class CanvasInteractions {
|
||||
handleMouseDown(e: MouseEvent): void {
|
||||
this.canvas.canvas.focus();
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
const mods = this.getModifierState(e);
|
||||
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
||||
this.canvas.render();
|
||||
// Don't render here - mask tool will handle its own drawing
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,11 +260,11 @@ export class CanvasInteractions {
|
||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||
|
||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||
if (e.shiftKey && e.ctrlKey) {
|
||||
if (mods.shift && mods.ctrl) {
|
||||
this.startCanvasMove(coords.world);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
if (mods.shift) {
|
||||
// Clear custom shape when starting canvas resize
|
||||
if (this.canvas.outputAreaShape) {
|
||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||
@@ -222,7 +290,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) { // Środkowy przycisk
|
||||
if (e.button === 1) { // Środkowy przycisk
|
||||
this.startPanning(e);
|
||||
return;
|
||||
}
|
||||
@@ -241,7 +309,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||
this.startPanningOrClearSelection(e);
|
||||
this.startPanning(e, true); // clearSelection = true
|
||||
}
|
||||
|
||||
handleMouseMove(e: MouseEvent): void {
|
||||
@@ -264,7 +332,7 @@ export class CanvasInteractions {
|
||||
switch (this.interaction.mode) {
|
||||
case 'drawingMask':
|
||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
||||
this.canvas.render();
|
||||
// Don't render during mask drawing - it's handled by mask tool internally
|
||||
break;
|
||||
case 'panning':
|
||||
this.panViewport(e);
|
||||
@@ -286,6 +354,10 @@ export class CanvasInteractions {
|
||||
break;
|
||||
default:
|
||||
this.updateCursor(coords.world);
|
||||
// Update brush cursor on overlay if mask tool is active
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.canvasRenderer.drawMaskBrushCursor(coords.world);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -300,6 +372,7 @@ export class CanvasInteractions {
|
||||
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||
// Render only once after drawing is complete
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
@@ -316,6 +389,16 @@ export class CanvasInteractions {
|
||||
this.logDragCompletion(coords);
|
||||
}
|
||||
|
||||
// Handle end of crop bounds transformation before resetting interaction state
|
||||
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
|
||||
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
|
||||
}
|
||||
|
||||
// Handle end of scale transformation (normal transform mode) before resetting interaction state
|
||||
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
|
||||
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
|
||||
}
|
||||
|
||||
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
||||
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
||||
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
||||
@@ -387,8 +470,17 @@ export class CanvasInteractions {
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
this.performZoomOperation(coords.world, zoomFactor);
|
||||
} else {
|
||||
// Layer transformation when layers are selected
|
||||
this.handleLayerWheelTransformation(e);
|
||||
// Check if mouse is over any selected layer
|
||||
const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y);
|
||||
|
||||
if (isOverSelectedLayer) {
|
||||
// Layer transformation when layers are selected and mouse is over selected layer
|
||||
this.handleLayerWheelTransformation(e);
|
||||
} else {
|
||||
// Zoom operation when mouse is not over selected layers
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
this.performZoomOperation(coords.world, zoomFactor);
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
@@ -398,14 +490,15 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
private handleLayerWheelTransformation(e: WheelEvent): void {
|
||||
const mods = this.getModifierState(e);
|
||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
if (e.shiftKey) {
|
||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
||||
if (mods.shift) {
|
||||
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
|
||||
} else {
|
||||
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
|
||||
this.handleLayerScaling(layer, mods.ctrl, e.deltaY);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -445,11 +538,14 @@ export class CanvasInteractions {
|
||||
layer.height *= scaleFactor;
|
||||
layer.x += (oldWidth - layer.width) / 2;
|
||||
layer.y += (oldHeight - layer.height) / 2;
|
||||
|
||||
// Handle wheel scaling end for layers with blend area
|
||||
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateGridBasedScaling(oldHeight: number, deltaY: number): number {
|
||||
const gridSize = 64;
|
||||
const gridSize = 64; // Grid size - could be made configurable in the future
|
||||
const direction = deltaY > 0 ? -1 : 1;
|
||||
let targetHeight;
|
||||
|
||||
@@ -474,6 +570,7 @@ export class CanvasInteractions {
|
||||
|
||||
handleKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
||||
if (e.key === 'Meta') this.interaction.isMetaPressed = true;
|
||||
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
|
||||
if (e.key === 'Alt') {
|
||||
this.interaction.isAltPressed = true;
|
||||
@@ -492,11 +589,12 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const mods = this.getModifierState(e);
|
||||
if (mods.ctrl || mods.meta) {
|
||||
let handled = true;
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'z':
|
||||
if (e.shiftKey) {
|
||||
if (mods.shift) {
|
||||
this.canvas.redo();
|
||||
} else {
|
||||
this.canvas.undo();
|
||||
@@ -523,7 +621,7 @@ export class CanvasInteractions {
|
||||
|
||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
const step = mods.shift ? 10 : 1;
|
||||
let needsRender = false;
|
||||
|
||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||
@@ -558,6 +656,7 @@ export class CanvasInteractions {
|
||||
|
||||
handleKeyUp(e: KeyboardEvent): void {
|
||||
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
|
||||
if (e.key === 'Meta') this.interaction.isMetaPressed = false;
|
||||
if (e.key === 'Shift') this.interaction.isShiftPressed = false;
|
||||
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
||||
if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false;
|
||||
@@ -577,6 +676,7 @@ export class CanvasInteractions {
|
||||
handleBlur(): void {
|
||||
log.debug('Window lost focus, resetting key states.');
|
||||
this.interaction.isCtrlPressed = false;
|
||||
this.interaction.isMetaPressed = false;
|
||||
this.interaction.isAltPressed = false;
|
||||
this.interaction.isShiftPressed = false;
|
||||
this.interaction.isSPressed = false;
|
||||
@@ -602,6 +702,12 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
updateCursor(worldCoords: Point): void {
|
||||
// If actively rotating, show grabbing cursor
|
||||
if (this.interaction.mode === 'rotating') {
|
||||
this.canvas.canvas.style.cursor = 'grabbing';
|
||||
return;
|
||||
}
|
||||
|
||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
|
||||
if (transformTarget) {
|
||||
@@ -626,7 +732,10 @@ export class CanvasInteractions {
|
||||
width: layer.width, height: layer.height,
|
||||
rotation: layer.rotation,
|
||||
centerX: layer.x + layer.width / 2,
|
||||
centerY: layer.y + layer.height / 2
|
||||
centerY: layer.y + layer.height / 2,
|
||||
originalWidth: layer.originalWidth,
|
||||
originalHeight: layer.originalHeight,
|
||||
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
|
||||
};
|
||||
this.interaction.dragStart = {...worldCoords};
|
||||
|
||||
@@ -647,7 +756,9 @@ export class CanvasInteractions {
|
||||
|
||||
prepareForDrag(layer: Layer, worldCoords: Point): void {
|
||||
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
||||
if (this.interaction.isCtrlPressed) {
|
||||
// Support both Ctrl (Windows/Linux) and Cmd (macOS) for multi-selection
|
||||
const mods = this.getModifierState();
|
||||
if (mods.ctrl || mods.meta) {
|
||||
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||
if (index === -1) {
|
||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||
@@ -665,14 +776,13 @@ export class CanvasInteractions {
|
||||
this.interaction.dragStart = {...worldCoords};
|
||||
}
|
||||
|
||||
startPanningOrClearSelection(e: MouseEvent): void {
|
||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
||||
if (!this.interaction.isCtrlPressed) {
|
||||
startPanning(e: MouseEvent, clearSelection: boolean = true): void {
|
||||
// Unified panning method - can optionally clear selection
|
||||
if (clearSelection && !this.interaction.isCtrlPressed) {
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
}
|
||||
this.interaction.mode = 'panning';
|
||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
startCanvasResize(worldCoords: Point): void {
|
||||
@@ -727,20 +837,18 @@ export class CanvasInteractions {
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
startPanning(e: MouseEvent): void {
|
||||
if (!this.interaction.isCtrlPressed) {
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
}
|
||||
this.interaction.mode = 'panning';
|
||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
panViewport(e: MouseEvent): void {
|
||||
const dx = e.clientX - this.interaction.panStart.x;
|
||||
const dy = e.clientY - this.interaction.panStart.y;
|
||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||
|
||||
// Update stroke overlay if mask tool is drawing during pan
|
||||
if (this.canvas.maskTool.isDrawing) {
|
||||
this.canvas.maskTool.handleViewportChange();
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.onViewportChange?.();
|
||||
}
|
||||
@@ -797,66 +905,159 @@ export class CanvasInteractions {
|
||||
|
||||
if (this.interaction.isCtrlPressed) {
|
||||
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
||||
const snappedMouseX = snapToGrid(mouseX);
|
||||
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX;
|
||||
const snappedMouseY = snapToGrid(mouseY);
|
||||
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY;
|
||||
mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
|
||||
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||
}
|
||||
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
||||
if (!o) return;
|
||||
|
||||
const handle = this.interaction.resizeHandle;
|
||||
const anchor = this.interaction.resizeAnchor;
|
||||
|
||||
const rad = o.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
// Vector from anchor to mouse
|
||||
const vecX = mouseX - anchor.x;
|
||||
const vecY = mouseY - anchor.y;
|
||||
|
||||
let newWidth = vecX * cos + vecY * sin;
|
||||
let newHeight = vecY * cos - vecX * sin;
|
||||
// Rotate vector to align with layer's local coordinates
|
||||
let localVecX = vecX * cos + vecY * sin;
|
||||
let localVecY = vecY * cos - vecX * sin;
|
||||
|
||||
if (isShiftPressed) {
|
||||
const originalAspectRatio = o.width / o.height;
|
||||
// Determine sign based on handle
|
||||
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||
|
||||
localVecX *= signX;
|
||||
localVecY *= signY;
|
||||
|
||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
||||
} else {
|
||||
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
||||
// If not a corner handle, keep original dimension
|
||||
if (signX === 0) localVecX = o.width;
|
||||
if (signY === 0) localVecY = o.height;
|
||||
|
||||
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
|
||||
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
|
||||
|
||||
// Calculate mouse movement since drag start, in the layer's local coordinate system.
|
||||
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
|
||||
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
|
||||
const mouseX_local = mouseX - (o.centerX ?? 0);
|
||||
const mouseY_local = mouseY - (o.centerY ?? 0);
|
||||
|
||||
// Rotate mouse delta into the layer's unrotated frame
|
||||
const deltaX_world = mouseX_local - dragStartX_local;
|
||||
const deltaY_world = mouseY_local - dragStartY_local;
|
||||
let mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
||||
let mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
|
||||
|
||||
if (layer.flipH) {
|
||||
mouseDeltaX_local *= -1;
|
||||
}
|
||||
if (layer.flipV) {
|
||||
mouseDeltaY_local *= -1;
|
||||
}
|
||||
|
||||
// Convert the on-screen mouse delta to an image-space delta.
|
||||
const screenToImageScaleX = o.originalWidth / o.width;
|
||||
const screenToImageScaleY = o.originalHeight / o.height;
|
||||
|
||||
const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
|
||||
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
|
||||
|
||||
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
|
||||
|
||||
// Apply the image-space delta to the appropriate edges of the crop bounds
|
||||
const isFlippedH = layer.flipH;
|
||||
const isFlippedV = layer.flipV;
|
||||
|
||||
if (handle?.includes('w')) {
|
||||
if (isFlippedH) newCropBounds.width += delta_image_x;
|
||||
else {
|
||||
newCropBounds.x += delta_image_x;
|
||||
newCropBounds.width -= delta_image_x;
|
||||
}
|
||||
}
|
||||
if (handle?.includes('e')) {
|
||||
if (isFlippedH) {
|
||||
newCropBounds.x += delta_image_x;
|
||||
newCropBounds.width -= delta_image_x;
|
||||
} else newCropBounds.width += delta_image_x;
|
||||
}
|
||||
if (handle?.includes('n')) {
|
||||
if (isFlippedV) newCropBounds.height += delta_image_y;
|
||||
else {
|
||||
newCropBounds.y += delta_image_y;
|
||||
newCropBounds.height -= delta_image_y;
|
||||
}
|
||||
}
|
||||
if (handle?.includes('s')) {
|
||||
if (isFlippedV) {
|
||||
newCropBounds.y += delta_image_y;
|
||||
newCropBounds.height -= delta_image_y;
|
||||
} else newCropBounds.height += delta_image_y;
|
||||
}
|
||||
|
||||
// Clamp crop bounds to stay within the original image and maintain minimum size
|
||||
if (newCropBounds.width < 1) {
|
||||
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1;
|
||||
newCropBounds.width = 1;
|
||||
}
|
||||
if (newCropBounds.height < 1) {
|
||||
if (handle?.includes('n')) newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1;
|
||||
newCropBounds.height = 1;
|
||||
}
|
||||
if (newCropBounds.x < 0) {
|
||||
newCropBounds.width += newCropBounds.x;
|
||||
newCropBounds.x = 0;
|
||||
}
|
||||
if (newCropBounds.y < 0) {
|
||||
newCropBounds.height += newCropBounds.y;
|
||||
newCropBounds.y = 0;
|
||||
}
|
||||
if (newCropBounds.x + newCropBounds.width > o.originalWidth) {
|
||||
newCropBounds.width = o.originalWidth - newCropBounds.x;
|
||||
}
|
||||
if (newCropBounds.y + newCropBounds.height > o.originalHeight) {
|
||||
newCropBounds.height = o.originalHeight - newCropBounds.y;
|
||||
}
|
||||
|
||||
layer.cropBounds = newCropBounds;
|
||||
|
||||
} else {
|
||||
// TRANSFORM MODE: Resize the layer's main transform frame
|
||||
let newWidth = localVecX;
|
||||
let newHeight = localVecY;
|
||||
|
||||
if (isShiftPressed) {
|
||||
const originalAspectRatio = o.width / o.height;
|
||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
||||
} else {
|
||||
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
if (newWidth < 10) newWidth = 10;
|
||||
if (newHeight < 10) newHeight = 10;
|
||||
|
||||
layer.width = newWidth;
|
||||
layer.height = newHeight;
|
||||
|
||||
// Update position to keep anchor point fixed
|
||||
const deltaW = layer.width - o.width;
|
||||
const deltaH = layer.height - o.height;
|
||||
const shiftX = (deltaW / 2) * signX;
|
||||
const shiftY = (deltaH / 2) * signY;
|
||||
const worldShiftX = shiftX * cos - shiftY * sin;
|
||||
const worldShiftY = shiftX * sin + shiftY * cos;
|
||||
const newCenterX = o.centerX + worldShiftX;
|
||||
const newCenterY = o.centerY + worldShiftY;
|
||||
layer.x = newCenterX - layer.width / 2;
|
||||
layer.y = newCenterY - layer.height / 2;
|
||||
}
|
||||
|
||||
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||
let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||
|
||||
newWidth *= signX;
|
||||
newHeight *= signY;
|
||||
|
||||
if (signX === 0) newWidth = o.width;
|
||||
if (signY === 0) newHeight = o.height;
|
||||
|
||||
if (newWidth < 10) newWidth = 10;
|
||||
if (newHeight < 10) newHeight = 10;
|
||||
|
||||
layer.width = newWidth;
|
||||
layer.height = newHeight;
|
||||
|
||||
const deltaW = newWidth - o.width;
|
||||
const deltaH = newHeight - o.height;
|
||||
|
||||
const shiftX = (deltaW / 2) * signX;
|
||||
const shiftY = (deltaH / 2) * signY;
|
||||
|
||||
const worldShiftX = shiftX * cos - shiftY * sin;
|
||||
const worldShiftY = shiftX * sin + shiftY * cos;
|
||||
|
||||
const newCenterX = o.centerX + worldShiftX;
|
||||
const newCenterY = o.centerY + worldShiftY;
|
||||
|
||||
layer.x = newCenterX - layer.width / 2;
|
||||
layer.y = newCenterY - layer.height / 2;
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
@@ -865,7 +1066,7 @@ export class CanvasInteractions {
|
||||
if (!layer) return;
|
||||
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
||||
if (!o) return;
|
||||
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
|
||||
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
||||
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer } from './types';
|
||||
|
||||
@@ -33,6 +34,9 @@ export class CanvasLayersPanel {
|
||||
// Preload icons
|
||||
this.initializeIcons();
|
||||
|
||||
// Load CSS for layers panel
|
||||
addStylesheet(getUrl('./css/layers_panel.css'));
|
||||
|
||||
log.info('CanvasLayersPanel initialized');
|
||||
}
|
||||
|
||||
@@ -47,23 +51,16 @@ export class CanvasLayersPanel {
|
||||
|
||||
private createIconElement(toolName: string, size: number = 16): HTMLElement {
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
iconContainer.className = 'icon-container';
|
||||
iconContainer.style.width = `${size}px`;
|
||||
iconContainer.style.height = `${size}px`;
|
||||
|
||||
const icon = iconLoader.getIcon(toolName);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode() as HTMLImageElement;
|
||||
img.style.cssText = `
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
filter: brightness(0) invert(1);
|
||||
`;
|
||||
img.style.width = `${size}px`;
|
||||
img.style.height = `${size}px`;
|
||||
iconContainer.appendChild(img);
|
||||
} else if (icon instanceof HTMLCanvasElement) {
|
||||
const { canvas, ctx } = createCanvas(size, size);
|
||||
@@ -74,9 +71,9 @@ export class CanvasLayersPanel {
|
||||
}
|
||||
} else {
|
||||
// Fallback text
|
||||
iconContainer.classList.add('fallback-text');
|
||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||
iconContainer.style.color = '#ffffff';
|
||||
}
|
||||
|
||||
return iconContainer;
|
||||
@@ -88,25 +85,16 @@ export class CanvasLayersPanel {
|
||||
} else {
|
||||
// Create a "hidden" version of the visibility icon
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
`;
|
||||
iconContainer.className = 'icon-container visibility-hidden';
|
||||
iconContainer.style.width = '16px';
|
||||
iconContainer.style.height = '16px';
|
||||
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode() as HTMLImageElement;
|
||||
img.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.3;
|
||||
`;
|
||||
img.style.width = '16px';
|
||||
img.style.height = '16px';
|
||||
iconContainer.appendChild(img);
|
||||
} else if (icon instanceof HTMLCanvasElement) {
|
||||
const { canvas, ctx } = createCanvas(16, 16);
|
||||
@@ -118,9 +106,9 @@ export class CanvasLayersPanel {
|
||||
}
|
||||
} else {
|
||||
// Fallback
|
||||
iconContainer.classList.add('fallback-text');
|
||||
iconContainer.textContent = 'H';
|
||||
iconContainer.style.fontSize = '10px';
|
||||
iconContainer.style.color = '#888888';
|
||||
}
|
||||
|
||||
return iconContainer;
|
||||
@@ -144,8 +132,6 @@ export class CanvasLayersPanel {
|
||||
`;
|
||||
|
||||
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
|
||||
|
||||
this.injectStyles();
|
||||
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
@@ -163,218 +149,10 @@ export class CanvasLayersPanel {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
injectStyles(): void {
|
||||
const styleId = 'layers-panel-styles';
|
||||
if (document.getElementById(styleId)) {
|
||||
return; // Style już istnieją
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.layers-panel {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layers-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.layers-panel-title {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layers-panel-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layers-btn {
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.layers-btn:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.layers-btn:active {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layers-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 4px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.layer-row.selected {
|
||||
background: #2d5aa0 !important;
|
||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||
}
|
||||
|
||||
.layer-row.dragging {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
|
||||
.layer-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layer-thumbnail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name.editing {
|
||||
background: #4a4a4a;
|
||||
border: 1px solid #6a6a6a;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drag-insertion-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #4a7bc8;
|
||||
border-radius: 1px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
log.debug('Styles injected');
|
||||
}
|
||||
|
||||
setupControlButtons(): void {
|
||||
if (!this.container) return;
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
|
||||
|
||||
// Add delete icon to button
|
||||
if (deleteBtn) {
|
||||
@@ -386,6 +164,9 @@ export class CanvasLayersPanel {
|
||||
log.info('Delete layer button clicked');
|
||||
this.deleteSelectedLayers();
|
||||
});
|
||||
|
||||
// Initial button state update
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
renderLayers(): void {
|
||||
@@ -495,6 +276,7 @@ export class CanvasLayersPanel {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||
this.canvas.updateSelection(newSelection);
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -532,7 +314,8 @@ export class CanvasLayersPanel {
|
||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
|
||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||
this.updateSelectionAppearance();
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
|
||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||
}
|
||||
@@ -751,12 +534,32 @@ export class CanvasLayersPanel {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
|
||||
*/
|
||||
updateButtonStates(): void {
|
||||
if (!this.container) return;
|
||||
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
|
||||
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
|
||||
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = !hasSelectedLayers;
|
||||
deleteBtn.title = hasSelectedLayers
|
||||
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
|
||||
: 'No layers selected';
|
||||
}
|
||||
|
||||
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||
*/
|
||||
onSelectionChanged(): void {
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
|
||||
@@ -8,12 +8,19 @@ export class CanvasRenderer {
|
||||
lastRenderTime: any;
|
||||
renderAnimationFrame: any;
|
||||
renderInterval: any;
|
||||
// Overlay used to preview in-progress mask strokes (separate from cursor overlay)
|
||||
strokeOverlayCanvas!: HTMLCanvasElement;
|
||||
strokeOverlayCtx!: CanvasRenderingContext2D;
|
||||
constructor(canvas: any) {
|
||||
this.canvas = canvas;
|
||||
this.renderAnimationFrame = null;
|
||||
this.lastRenderTime = 0;
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
|
||||
// Initialize overlay canvases
|
||||
this.initOverlay();
|
||||
this.initStrokeOverlay();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,9 +148,11 @@ export class CanvasRenderer {
|
||||
ctx.save();
|
||||
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
// In draw mask mode, use the previewOpacity value from the slider
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||
} else {
|
||||
// When not in draw mask mode, show mask at full opacity
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
@@ -205,6 +214,12 @@ export class CanvasRenderer {
|
||||
}
|
||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||
|
||||
// Ensure overlay canvases are in DOM and properly sized
|
||||
this.addOverlayToDOM();
|
||||
this.updateOverlaySize();
|
||||
this.addStrokeOverlayToDOM();
|
||||
this.updateStrokeOverlaySize();
|
||||
|
||||
// Update Batch Preview UI positions
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
||||
@@ -532,46 +547,89 @@ export class CanvasRenderer {
|
||||
drawSelectionFrame(ctx: any, layer: any) {
|
||||
const lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = lineWidth;
|
||||
|
||||
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||
// --- CROP MODE ---
|
||||
ctx.lineWidth = lineWidth;
|
||||
|
||||
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||
ctx.strokeStyle = '#007bff';
|
||||
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 2. Draw solid blue line for the crop bounds
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
const s = layer.cropBounds;
|
||||
|
||||
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||
const cropRectW = s.width * layerScaleX;
|
||||
const cropRectH = s.height * layerScaleY;
|
||||
|
||||
ctx.strokeStyle = '#007bff'; // Solid blue
|
||||
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
|
||||
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
|
||||
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
|
||||
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
|
||||
|
||||
} else {
|
||||
// --- TRANSFORM MODE ---
|
||||
ctx.strokeStyle = '#00ff00'; // Green
|
||||
ctx.lineWidth = lineWidth;
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
// Draw adaptive solid green line for transform frame
|
||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||
|
||||
// Draw line to rotation handle
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
const startY = layer.flipV ? halfH : -halfH;
|
||||
const endY = startY + (layer.flipV ? 1 : -1) * (20 / this.canvas.viewport.zoom);
|
||||
ctx.moveTo(0, startY);
|
||||
ctx.lineTo(0, endY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
// Górna krawędź
|
||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||
// Prawa krawędź
|
||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||
// Dolna krawędź
|
||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||
// Lewa krawędź
|
||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||
|
||||
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -layer.height / 2);
|
||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||
ctx.stroke();
|
||||
|
||||
// Rysuj uchwyty
|
||||
// --- DRAW HANDLES (Unified Logic) ---
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
|
||||
for (const key in handles) {
|
||||
// Skip rotation handle in crop mode
|
||||
if (layer.cropMode && key === 'rot') continue;
|
||||
|
||||
const point = handles[key];
|
||||
ctx.beginPath();
|
||||
const localX = point.x - (layer.x + layer.width / 2);
|
||||
const localY = point.y - (layer.y + layer.height / 2);
|
||||
// The handle position is already in world space.
|
||||
// We need to convert it to the layer's local, un-rotated space.
|
||||
const dx = point.x - centerX;
|
||||
const dy = point.y - centerY;
|
||||
|
||||
// "Un-rotate" the position to get it in the layer's local, un-rotated space
|
||||
const rad = -layer.rotation * Math.PI / 180;
|
||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const localX = dx * cos - dy * sin;
|
||||
const localY = dx * sin + dy * cos;
|
||||
|
||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
||||
// The context is already flipped. We need to flip the coordinates
|
||||
// to match the visual transformation, so the arc is drawn in the correct place.
|
||||
const finalX = localX * (layer.flipH ? -1 : 1);
|
||||
const finalY = localY * (layer.flipV ? -1 : 1);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
@@ -667,4 +725,290 @@ export class CanvasRenderer {
|
||||
padding: 8
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize overlay canvas for lightweight overlays like brush cursor
|
||||
*/
|
||||
initOverlay(): void {
|
||||
// Setup overlay canvas to match main canvas
|
||||
this.updateOverlaySize();
|
||||
|
||||
// Position overlay canvas on top of main canvas
|
||||
this.canvas.overlayCanvas.style.position = 'absolute';
|
||||
this.canvas.overlayCanvas.style.left = '0px';
|
||||
this.canvas.overlayCanvas.style.top = '0px';
|
||||
this.canvas.overlayCanvas.style.pointerEvents = 'none';
|
||||
this.canvas.overlayCanvas.style.zIndex = '20'; // Above other overlays
|
||||
|
||||
// Add overlay to DOM when main canvas is added
|
||||
this.addOverlayToDOM();
|
||||
|
||||
log.debug('Overlay canvas initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add overlay canvas to DOM if main canvas has a parent
|
||||
*/
|
||||
addOverlayToDOM(): void {
|
||||
if (this.canvas.canvas.parentElement && !this.canvas.overlayCanvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.canvas.overlayCanvas);
|
||||
log.debug('Overlay canvas added to DOM');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update overlay canvas size to match main canvas
|
||||
*/
|
||||
updateOverlaySize(): void {
|
||||
if (this.canvas.overlayCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||
this.canvas.overlayCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||
|
||||
this.canvas.overlayCanvas.width = Math.max(1, this.canvas.canvas.clientWidth);
|
||||
this.canvas.overlayCanvas.height = Math.max(1, this.canvas.canvas.clientHeight);
|
||||
|
||||
log.debug(`Overlay canvas resized to ${this.canvas.overlayCanvas.width}x${this.canvas.overlayCanvas.height}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear overlay canvas
|
||||
*/
|
||||
clearOverlay(): void {
|
||||
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a dedicated overlay for real-time mask stroke preview
|
||||
*/
|
||||
initStrokeOverlay(): void {
|
||||
// Create canvas if not created yet
|
||||
if (!this.strokeOverlayCanvas) {
|
||||
this.strokeOverlayCanvas = document.createElement('canvas');
|
||||
const ctx = this.strokeOverlayCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get 2D context for stroke overlay canvas');
|
||||
}
|
||||
this.strokeOverlayCtx = ctx;
|
||||
}
|
||||
|
||||
// Size match main canvas
|
||||
this.updateStrokeOverlaySize();
|
||||
|
||||
// Position above main canvas but below cursor overlay
|
||||
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||
this.strokeOverlayCanvas.style.left = '1px';
|
||||
this.strokeOverlayCanvas.style.top = '1px';
|
||||
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||
// Opacity is now controlled by MaskTool.previewOpacity
|
||||
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
|
||||
|
||||
// Add to DOM
|
||||
this.addStrokeOverlayToDOM();
|
||||
log.debug('Stroke overlay canvas initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add stroke overlay canvas to DOM if needed
|
||||
*/
|
||||
addStrokeOverlayToDOM(): void {
|
||||
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
|
||||
log.debug('Stroke overlay canvas added to DOM');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure stroke overlay size matches main canvas
|
||||
*/
|
||||
updateStrokeOverlaySize(): void {
|
||||
const w = Math.max(1, this.canvas.canvas.clientWidth);
|
||||
const h = Math.max(1, this.canvas.canvas.clientHeight);
|
||||
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
|
||||
this.strokeOverlayCanvas.width = w;
|
||||
this.strokeOverlayCanvas.height = h;
|
||||
log.debug(`Stroke overlay resized to ${w}x${h}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stroke overlay
|
||||
*/
|
||||
clearMaskStrokeOverlay(): void {
|
||||
if (!this.strokeOverlayCtx) return;
|
||||
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a preview stroke segment onto the stroke overlay in screen space
|
||||
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
|
||||
*/
|
||||
drawMaskStrokeSegment(startWorld: { x: number; y: number }, endWorld: { x: number; y: number }): void {
|
||||
// Ensure overlay is present and sized
|
||||
this.updateStrokeOverlaySize();
|
||||
|
||||
const zoom = this.canvas.viewport.zoom;
|
||||
const toScreen = (p: { x: number; y: number }) => ({
|
||||
x: (p.x - this.canvas.viewport.x) * zoom,
|
||||
y: (p.y - this.canvas.viewport.y) * zoom
|
||||
});
|
||||
|
||||
const startScreen = toScreen(startWorld);
|
||||
const endScreen = toScreen(endWorld);
|
||||
|
||||
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
|
||||
const hardness = this.canvas.maskTool.brushHardness;
|
||||
const strength = this.canvas.maskTool.brushStrength;
|
||||
|
||||
// If strength is 0, don't draw anything
|
||||
if (strength <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.strokeOverlayCtx.save();
|
||||
|
||||
// Draw line segment exactly as MaskTool does
|
||||
this.strokeOverlayCtx.beginPath();
|
||||
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
|
||||
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
|
||||
|
||||
// Match the gradient setup from MaskTool's drawLineOnChunk
|
||||
if (hardness === 1) {
|
||||
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
|
||||
} else {
|
||||
const innerRadius = brushRadius * hardness;
|
||||
const gradient = this.strokeOverlayCtx.createRadialGradient(
|
||||
endScreen.x, endScreen.y, innerRadius,
|
||||
endScreen.x, endScreen.y, brushRadius
|
||||
);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||
this.strokeOverlayCtx.strokeStyle = gradient;
|
||||
}
|
||||
|
||||
// Match line properties from MaskTool
|
||||
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
|
||||
this.strokeOverlayCtx.lineCap = 'round';
|
||||
this.strokeOverlayCtx.lineJoin = 'round';
|
||||
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
|
||||
this.strokeOverlayCtx.stroke();
|
||||
|
||||
this.strokeOverlayCtx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws the entire stroke overlay from world coordinates
|
||||
* Used when viewport changes during drawing to maintain visual consistency
|
||||
*/
|
||||
redrawMaskStrokeOverlay(strokePoints: { x: number; y: number }[]): void {
|
||||
if (strokePoints.length < 2) return;
|
||||
|
||||
// Clear the overlay first
|
||||
this.clearMaskStrokeOverlay();
|
||||
|
||||
// Redraw all segments with current viewport
|
||||
for (let i = 1; i < strokePoints.length; i++) {
|
||||
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
|
||||
* @param worldPoint World coordinates of cursor
|
||||
*/
|
||||
drawMaskBrushCursor(worldPoint: { x: number, y: number }): void {
|
||||
if (!this.canvas.maskTool.isActive || !this.canvas.isMouseOver) {
|
||||
this.clearOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update overlay size if needed
|
||||
this.updateOverlaySize();
|
||||
|
||||
// Clear previous cursor
|
||||
this.clearOverlay();
|
||||
|
||||
// Convert world coordinates to screen coordinates
|
||||
const screenX = (worldPoint.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (worldPoint.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
// Get brush properties
|
||||
const brushRadius = (this.canvas.maskTool.brushSize / 2) * this.canvas.viewport.zoom;
|
||||
const brushStrength = this.canvas.maskTool.brushStrength;
|
||||
const brushHardness = this.canvas.maskTool.brushHardness;
|
||||
|
||||
// Save context state
|
||||
this.canvas.overlayCtx.save();
|
||||
|
||||
// If strength is 0, just draw outline
|
||||
if (brushStrength > 0) {
|
||||
// Draw inner fill to visualize brush effect - matches actual brush rendering
|
||||
const gradient = this.canvas.overlayCtx.createRadialGradient(
|
||||
screenX, screenY, 0,
|
||||
screenX, screenY, brushRadius
|
||||
);
|
||||
|
||||
// Preview alpha - subtle to not obscure content
|
||||
const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
|
||||
|
||||
if (brushHardness === 1) {
|
||||
// Hard brush - uniform fill within radius
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||
} else {
|
||||
// Soft brush - gradient fade matching actual brush
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||
if (brushHardness > 0) {
|
||||
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||
}
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||
}
|
||||
|
||||
this.canvas.overlayCtx.beginPath();
|
||||
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||
this.canvas.overlayCtx.fillStyle = gradient;
|
||||
this.canvas.overlayCtx.fill();
|
||||
}
|
||||
|
||||
// Draw outer circle (SIZE indicator)
|
||||
this.canvas.overlayCtx.beginPath();
|
||||
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||
|
||||
// Stroke opacity based on strength (dimmer when strength is 0)
|
||||
const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
|
||||
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||
this.canvas.overlayCtx.lineWidth = 1.5;
|
||||
|
||||
// Visual feedback for hardness
|
||||
if (brushHardness > 0.8) {
|
||||
// Hard brush - solid line
|
||||
this.canvas.overlayCtx.setLineDash([]);
|
||||
} else {
|
||||
// Soft brush - dashed line
|
||||
const dashLength = 2 + (1 - brushHardness) * 4;
|
||||
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
|
||||
}
|
||||
|
||||
this.canvas.overlayCtx.stroke();
|
||||
|
||||
// Center dot for small brushes
|
||||
if (brushRadius < 5) {
|
||||
this.canvas.overlayCtx.beginPath();
|
||||
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);
|
||||
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||
this.canvas.overlayCtx.fill();
|
||||
}
|
||||
|
||||
// Restore context state
|
||||
this.canvas.overlayCtx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update overlay position when viewport changes
|
||||
*/
|
||||
updateOverlayPosition(): void {
|
||||
// Overlay canvas is positioned absolutely, so it doesn't need repositioning
|
||||
// Just ensure it's the right size
|
||||
this.updateOverlaySize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,40 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
});
|
||||
const imageCache = new ImageCache();
|
||||
|
||||
/**
|
||||
* Helper function to update the icon of a switch component.
|
||||
* @param knobIconEl The HTML element for the switch's knob icon.
|
||||
* @param isChecked The current state of the switch (e.g., checkbox.checked).
|
||||
* @param iconToolTrue The icon tool name for the 'true' state.
|
||||
* @param iconToolFalse The icon tool name for the 'false' state.
|
||||
* @param fallbackTrue The text fallback for the 'true' state.
|
||||
* @param fallbackFalse The text fallback for the 'false' state.
|
||||
*/
|
||||
const updateSwitchIcon = (
|
||||
knobIconEl: HTMLElement,
|
||||
isChecked: boolean,
|
||||
iconToolTrue: string,
|
||||
iconToolFalse: string,
|
||||
fallbackTrue: string,
|
||||
fallbackFalse: string
|
||||
) => {
|
||||
if (!knobIconEl) return;
|
||||
|
||||
const iconTool = isChecked ? iconToolTrue : iconToolFalse;
|
||||
const fallbackText = isChecked ? fallbackTrue : fallbackFalse;
|
||||
const icon = iconLoader.getIcon(iconTool);
|
||||
|
||||
knobIconEl.innerHTML = ''; // Clear previous icon
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const clonedIcon = icon.cloneNode() as HTMLImageElement;
|
||||
clonedIcon.style.width = '20px';
|
||||
clonedIcon.style.height = '20px';
|
||||
knobIconEl.appendChild(clonedIcon);
|
||||
} else {
|
||||
knobIconEl.textContent = fallbackText;
|
||||
}
|
||||
};
|
||||
|
||||
const helpTooltip = $el("div.painter-tooltip", {
|
||||
id: `painter-help-tooltip-${node.id}`,
|
||||
}) as HTMLDivElement;
|
||||
@@ -97,7 +131,6 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
}),
|
||||
$el("button.painter-button.icon-button", {
|
||||
textContent: "?",
|
||||
title: "Show shortcuts",
|
||||
onmouseenter: (e: MouseEvent) => {
|
||||
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
||||
showTooltip(e.target as HTMLElement, content);
|
||||
@@ -176,37 +209,56 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
])
|
||||
]);
|
||||
|
||||
// Helper function to get current tooltip content based on switch state
|
||||
const getCurrentTooltipContent = () => {
|
||||
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
|
||||
return checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
||||
};
|
||||
|
||||
// Helper function to update tooltip content if it's currently visible
|
||||
const updateTooltipIfVisible = () => {
|
||||
// Only update if tooltip is currently visible
|
||||
if (helpTooltip.style.display === 'block') {
|
||||
const tooltipContent = getCurrentTooltipContent();
|
||||
showTooltip(switchEl, tooltipContent);
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltip logic
|
||||
switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
|
||||
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
|
||||
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
||||
const tooltipContent = getCurrentTooltipContent();
|
||||
showTooltip(switchEl, tooltipContent);
|
||||
});
|
||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||
|
||||
// Dynamic icon and text update on toggle
|
||||
// Dynamic icon update on toggle
|
||||
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement;
|
||||
|
||||
const updateSwitchView = (isClipspace: boolean) => {
|
||||
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
|
||||
const icon = iconLoader.getIcon(iconTool);
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
knobIcon.innerHTML = '';
|
||||
const clonedIcon = icon.cloneNode() as HTMLImageElement;
|
||||
clonedIcon.style.width = '20px';
|
||||
clonedIcon.style.height = '20px';
|
||||
knobIcon.appendChild(clonedIcon);
|
||||
} else {
|
||||
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => updateSwitchView(input.checked));
|
||||
input.addEventListener('change', () => {
|
||||
updateSwitchIcon(
|
||||
knobIcon,
|
||||
input.checked,
|
||||
LAYERFORGE_TOOLS.CLIPSPACE,
|
||||
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
|
||||
"🗂️",
|
||||
"📋"
|
||||
);
|
||||
|
||||
// Update tooltip content immediately after state change
|
||||
updateTooltipIfVisible();
|
||||
});
|
||||
|
||||
// Initial state
|
||||
iconLoader.preloadToolIcons().then(() => {
|
||||
updateSwitchView(isClipspace);
|
||||
updateSwitchIcon(
|
||||
knobIcon,
|
||||
isClipspace,
|
||||
LAYERFORGE_TOOLS.CLIPSPACE,
|
||||
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
|
||||
"🗂️",
|
||||
"📋"
|
||||
);
|
||||
});
|
||||
|
||||
return switchEl;
|
||||
@@ -326,6 +378,68 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
|
||||
$el("div.painter-separator"),
|
||||
$el("div.painter-button-group", {}, [
|
||||
(() => {
|
||||
const switchEl = $el("label.clipboard-switch.requires-selection", {
|
||||
id: `crop-transform-switch-${node.id}`,
|
||||
title: "Toggle between Transform and Crop mode for selected layer(s)"
|
||||
}, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: false,
|
||||
onchange: (e: Event) => {
|
||||
const isCropMode = (e.target as HTMLInputElement).checked;
|
||||
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||
if (selectedLayers.length === 0) return;
|
||||
|
||||
selectedLayers.forEach((layer: Layer) => {
|
||||
layer.cropMode = isCropMode;
|
||||
if (isCropMode && !layer.cropBounds) {
|
||||
layer.cropBounds = { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||
}
|
||||
});
|
||||
|
||||
canvas.saveState();
|
||||
canvas.render();
|
||||
}
|
||||
}),
|
||||
$el("span.switch-track"),
|
||||
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
|
||||
$el("span.text-clipspace", {}, ["Crop"]),
|
||||
$el("span.text-system", {}, ["Transform"])
|
||||
]),
|
||||
$el("span.switch-knob", {}, [
|
||||
$el("span.switch-icon", { id: `crop-transform-icon-${node.id}`})
|
||||
])
|
||||
]);
|
||||
|
||||
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
const knobIcon = switchEl.querySelector('.switch-icon') as HTMLElement;
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
updateSwitchIcon(
|
||||
knobIcon,
|
||||
input.checked,
|
||||
LAYERFORGE_TOOLS.CROP,
|
||||
LAYERFORGE_TOOLS.TRANSFORM,
|
||||
"✂️",
|
||||
"✥"
|
||||
);
|
||||
});
|
||||
|
||||
// Initial state
|
||||
iconLoader.preloadToolIcons().then(() => {
|
||||
updateSwitchIcon(
|
||||
knobIcon,
|
||||
false, // Initial state is transform
|
||||
LAYERFORGE_TOOLS.CROP,
|
||||
LAYERFORGE_TOOLS.TRANSFORM,
|
||||
"✂️",
|
||||
"✥"
|
||||
);
|
||||
});
|
||||
|
||||
return switchEl;
|
||||
})(),
|
||||
$el("button.painter-button.requires-selection", {
|
||||
textContent: "Rotate +90°",
|
||||
title: "Rotate selected layer(s) by +90 degrees",
|
||||
@@ -401,6 +515,10 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
|
||||
canvas.layers[selectedLayerIndex] = newLayer;
|
||||
canvas.canvasSelection.updateSelection([newLayer]);
|
||||
|
||||
// Invalidate processed image cache when layer image changes (matting)
|
||||
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
|
||||
|
||||
canvas.render();
|
||||
canvas.saveState();
|
||||
showSuccessNotification("Background removed successfully!");
|
||||
@@ -436,7 +554,8 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
$el("div.painter-button-group", {id: "mask-controls"}, [
|
||||
$el("label.clipboard-switch.mask-switch", {
|
||||
id: `toggle-mask-switch-${node.id}`,
|
||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
|
||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" },
|
||||
title: "Toggle mask overlay visibility on canvas (mask still affects output when disabled)"
|
||||
}, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
@@ -521,6 +640,24 @@ $el("label.clipboard-switch.mask-switch", {
|
||||
setTimeout(() => canvas.render(), 0);
|
||||
}
|
||||
}),
|
||||
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
||||
$el("label", {for: "preview-opacity-slider", textContent: "Mask Opacity:"}),
|
||||
$el("input", {
|
||||
id: "preview-opacity-slider",
|
||||
type: "range",
|
||||
min: "0",
|
||||
max: "1",
|
||||
step: "0.05",
|
||||
value: "0.5",
|
||||
oninput: (e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
canvas.maskTool.setPreviewOpacity(parseFloat(value));
|
||||
const valueEl = document.getElementById('preview-opacity-value');
|
||||
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||
}
|
||||
}),
|
||||
$el("div.slider-value", {id: "preview-opacity-value"}, ["50%"])
|
||||
]),
|
||||
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
||||
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
|
||||
$el("input", {
|
||||
@@ -672,18 +809,50 @@ $el("label.clipboard-switch.mask-switch", {
|
||||
const updateButtonStates = () => {
|
||||
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
||||
const hasSelection = selectionCount > 0;
|
||||
controlPanel.querySelectorAll('.requires-selection').forEach((btn: any) => {
|
||||
const button = btn as HTMLButtonElement;
|
||||
if (button.textContent === 'Fuse') {
|
||||
button.disabled = selectionCount < 2;
|
||||
} else {
|
||||
button.disabled = !hasSelection;
|
||||
|
||||
// --- Handle Standard Buttons ---
|
||||
controlPanel.querySelectorAll('.requires-selection').forEach((el: any) => {
|
||||
if (el.tagName === 'BUTTON') {
|
||||
if (el.textContent === 'Fuse') {
|
||||
el.disabled = selectionCount < 2;
|
||||
} else {
|
||||
el.disabled = !hasSelection;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mattingBtn = controlPanel.querySelector('.matting-button') as HTMLButtonElement;
|
||||
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
||||
mattingBtn.disabled = selectionCount !== 1;
|
||||
}
|
||||
|
||||
// --- Handle Crop/Transform Switch ---
|
||||
const switchEl = controlPanel.querySelector(`#crop-transform-switch-${node.id}`) as HTMLLabelElement;
|
||||
if (switchEl) {
|
||||
const input = switchEl.querySelector('input') as HTMLInputElement;
|
||||
const knobIcon = switchEl.querySelector('.switch-icon') as HTMLElement;
|
||||
|
||||
const isDisabled = !hasSelection;
|
||||
switchEl.classList.toggle('disabled', isDisabled);
|
||||
input.disabled = isDisabled;
|
||||
|
||||
if (!isDisabled) {
|
||||
const isCropMode = canvas.canvasSelection.selectedLayers[0].cropMode || false;
|
||||
if (input.checked !== isCropMode) {
|
||||
input.checked = isCropMode;
|
||||
}
|
||||
|
||||
// Update icon view
|
||||
updateSwitchIcon(
|
||||
knobIcon,
|
||||
isCropMode,
|
||||
LAYERFORGE_TOOLS.CROP,
|
||||
LAYERFORGE_TOOLS.TRANSFORM,
|
||||
"✂️",
|
||||
"✥"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
||||
@@ -1014,7 +1183,7 @@ $el("label.clipboard-switch.mask-switch", {
|
||||
const canvasNodeInstances = new Map<number, CanvasWidget>();
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.CanvasNode",
|
||||
name: "Comfy.LayerForgeNode",
|
||||
|
||||
init() {
|
||||
addStylesheet(getUrl('./css/canvas_view.css'));
|
||||
@@ -1053,7 +1222,7 @@ app.registerExtension({
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
|
||||
if (nodeType.comfyClass === "CanvasNode") {
|
||||
if (nodeType.comfyClass === "LayerForgeNode") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
|
||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||
|
||||
220
src/MaskTool.ts
220
src/MaskTool.ts
@@ -21,9 +21,10 @@ interface MaskChunk {
|
||||
}
|
||||
|
||||
export class MaskTool {
|
||||
private brushHardness: number;
|
||||
private brushSize: number;
|
||||
private brushStrength: number;
|
||||
private _brushHardness: number;
|
||||
public brushSize: number;
|
||||
private _brushStrength: number;
|
||||
private _previewOpacity: number;
|
||||
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
|
||||
public isActive: boolean;
|
||||
public isDrawing: boolean;
|
||||
@@ -31,6 +32,9 @@ export class MaskTool {
|
||||
private lastPosition: Point | null;
|
||||
private mainCanvas: HTMLCanvasElement;
|
||||
|
||||
// Track strokes during drawing for efficient overlay updates
|
||||
private currentStrokePoints: Point[] = [];
|
||||
|
||||
// Chunked mask system
|
||||
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
|
||||
private chunkSize: number;
|
||||
@@ -72,6 +76,9 @@ export class MaskTool {
|
||||
this.mainCanvas = canvasInstance.canvas;
|
||||
this.onStateChange = callbacks.onStateChange || null;
|
||||
|
||||
// Initialize stroke tracking for overlay drawing
|
||||
this.currentStrokePoints = [];
|
||||
|
||||
// Initialize chunked mask system
|
||||
this.maskChunks = new Map();
|
||||
this.chunkSize = 512;
|
||||
@@ -96,8 +103,9 @@ export class MaskTool {
|
||||
this.isOverlayVisible = true;
|
||||
this.isActive = false;
|
||||
this.brushSize = 20;
|
||||
this.brushStrength = 0.5;
|
||||
this.brushHardness = 0.5;
|
||||
this._brushStrength = 0.5;
|
||||
this._brushHardness = 0.5;
|
||||
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||
this.isDrawing = false;
|
||||
this.lastPosition = null;
|
||||
|
||||
@@ -156,8 +164,31 @@ export class MaskTool {
|
||||
}
|
||||
}
|
||||
|
||||
// Getters for brush properties
|
||||
get brushStrength(): number {
|
||||
return this._brushStrength;
|
||||
}
|
||||
|
||||
get brushHardness(): number {
|
||||
return this._brushHardness;
|
||||
}
|
||||
|
||||
get previewOpacity(): number {
|
||||
return this._previewOpacity;
|
||||
}
|
||||
|
||||
setBrushHardness(hardness: number): void {
|
||||
this.brushHardness = Math.max(0, Math.min(1, hardness));
|
||||
this._brushHardness = Math.max(0, Math.min(1, hardness));
|
||||
}
|
||||
|
||||
setPreviewOpacity(opacity: number): void {
|
||||
this._previewOpacity = Math.max(0, Math.min(1, opacity));
|
||||
// Update the stroke overlay canvas opacity when preview opacity changes
|
||||
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
|
||||
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
|
||||
}
|
||||
// Trigger canvas render to update mask display opacity
|
||||
this.canvasInstance.render();
|
||||
}
|
||||
|
||||
initMaskCanvas(): void {
|
||||
@@ -867,7 +898,7 @@ export class MaskTool {
|
||||
}
|
||||
|
||||
setBrushStrength(strength: number): void {
|
||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
||||
this._brushStrength = Math.max(0, Math.min(1, strength));
|
||||
}
|
||||
|
||||
handleMouseDown(worldCoords: Point, viewCoords: Point): void {
|
||||
@@ -875,10 +906,12 @@ export class MaskTool {
|
||||
this.isDrawing = true;
|
||||
this.lastPosition = worldCoords;
|
||||
|
||||
// Activate chunks around the drawing position for performance
|
||||
this.updateActiveChunksForDrawing(worldCoords);
|
||||
// Initialize stroke tracking for live preview
|
||||
this.currentStrokePoints = [worldCoords];
|
||||
|
||||
// Clear any previous stroke overlay
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
|
||||
this.draw(worldCoords);
|
||||
this.clearPreview();
|
||||
}
|
||||
|
||||
@@ -888,16 +921,83 @@ export class MaskTool {
|
||||
}
|
||||
if (!this.isActive || !this.isDrawing) return;
|
||||
|
||||
// Dynamically update active chunks as user moves while drawing
|
||||
this.updateActiveChunksForDrawing(worldCoords);
|
||||
// Add point to stroke tracking
|
||||
this.currentStrokePoints.push(worldCoords);
|
||||
|
||||
// Draw interpolated segments for smooth strokes without gaps
|
||||
if (this.lastPosition) {
|
||||
// Calculate distance between last and current position
|
||||
const dx = worldCoords.x - this.lastPosition.x;
|
||||
const dy = worldCoords.y - this.lastPosition.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// If distance is small, just draw a single segment
|
||||
if (distance < this.brushSize / 4) {
|
||||
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
|
||||
} else {
|
||||
// Interpolate points for smooth drawing without gaps
|
||||
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
|
||||
|
||||
// Draw all interpolated segments
|
||||
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
|
||||
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(
|
||||
interpolatedPoints[i],
|
||||
interpolatedPoints[i + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.draw(worldCoords);
|
||||
this.lastPosition = worldCoords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates points between two positions to create smooth strokes without gaps
|
||||
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
|
||||
*/
|
||||
private interpolatePoints(start: Point, end: Point, distance: number): Point[] {
|
||||
const points: Point[] = [];
|
||||
|
||||
// Calculate number of interpolated points based on brush size
|
||||
// More points = smoother line
|
||||
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
|
||||
const numSteps = Math.ceil(distance / stepSize);
|
||||
|
||||
// Always include start point
|
||||
points.push(start);
|
||||
|
||||
// Interpolate intermediate points
|
||||
for (let i = 1; i < numSteps; i++) {
|
||||
const t = i / numSteps;
|
||||
points.push({
|
||||
x: start.x + (end.x - start.x) * t,
|
||||
y: start.y + (end.y - start.y) * t
|
||||
});
|
||||
}
|
||||
|
||||
// Always include end point
|
||||
points.push(end);
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when viewport changes during drawing to update stroke overlay
|
||||
* This ensures the stroke preview scales correctly with zoom changes
|
||||
*/
|
||||
handleViewportChange(): void {
|
||||
if (this.isDrawing && this.currentStrokePoints.length > 1) {
|
||||
// Redraw the entire stroke overlay with new viewport settings
|
||||
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
this.previewVisible = false;
|
||||
this.clearPreview();
|
||||
// Clear overlay canvases when mouse leaves
|
||||
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
}
|
||||
|
||||
handleMouseEnter(): void {
|
||||
@@ -908,11 +1008,18 @@ export class MaskTool {
|
||||
if (!this.isActive) return;
|
||||
if (this.isDrawing) {
|
||||
this.isDrawing = false;
|
||||
|
||||
// Commit the stroke from overlay to actual mask chunks
|
||||
this.commitStrokeToChunks();
|
||||
|
||||
// Clear stroke overlay and reset state
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
this.currentStrokePoints = [];
|
||||
this.lastPosition = null;
|
||||
this.currentDrawingChunk = null;
|
||||
|
||||
// After drawing is complete, update active canvas to show all chunks
|
||||
this.updateActiveMaskCanvas(true); // forceShowAll = true
|
||||
this.updateActiveMaskCanvas(true); // Force full update
|
||||
|
||||
this.completeMaskOperation();
|
||||
this.drawBrushPreview(viewCoords);
|
||||
@@ -932,6 +1039,44 @@ export class MaskTool {
|
||||
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits the current stroke from overlay to actual mask chunks
|
||||
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
|
||||
*/
|
||||
private commitStrokeToChunks(): void {
|
||||
if (this.currentStrokePoints.length < 2) {
|
||||
return; // Need at least 2 points for a stroke
|
||||
}
|
||||
|
||||
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
|
||||
|
||||
// Replay the entire stroke path with interpolation for smooth, accurate lines
|
||||
for (let i = 1; i < this.currentStrokePoints.length; i++) {
|
||||
const startPoint = this.currentStrokePoints[i - 1];
|
||||
const endPoint = this.currentStrokePoints[i];
|
||||
|
||||
// Calculate distance between points
|
||||
const dx = endPoint.x - startPoint.x;
|
||||
const dy = endPoint.y - startPoint.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < this.brushSize / 4) {
|
||||
// Small distance - draw single segment
|
||||
this.drawOnChunks(startPoint, endPoint);
|
||||
} else {
|
||||
// Large distance - interpolate for smooth line without gaps
|
||||
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
|
||||
|
||||
// Draw all interpolated segments
|
||||
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
|
||||
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Stroke committed to chunks successfully with interpolation");
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a line between two world coordinates on the appropriate chunks
|
||||
*/
|
||||
@@ -982,15 +1127,15 @@ export class MaskTool {
|
||||
|
||||
const gradientRadius = this.brushSize / 2;
|
||||
|
||||
if (this.brushHardness === 1) {
|
||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
||||
if (this._brushHardness === 1) {
|
||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||
} else {
|
||||
const innerRadius = gradientRadius * this.brushHardness;
|
||||
const innerRadius = gradientRadius * this._brushHardness;
|
||||
const gradient = chunk.ctx.createRadialGradient(
|
||||
endLocal.x, endLocal.y, innerRadius,
|
||||
endLocal.x, endLocal.y, gradientRadius
|
||||
);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this._brushStrength})`);
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||
chunk.ctx.strokeStyle = gradient;
|
||||
}
|
||||
@@ -1029,29 +1174,17 @@ export class MaskTool {
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates active canvas when drawing affects chunks with throttling to prevent lag
|
||||
* During drawing, only updates the affected active chunks for performance
|
||||
* Updates active canvas when drawing affects chunks
|
||||
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||
*/
|
||||
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
|
||||
// Calculate which chunks were affected by this drawing operation
|
||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
||||
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
|
||||
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
|
||||
|
||||
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
|
||||
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
|
||||
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
|
||||
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
|
||||
|
||||
// During drawing, only update affected chunks that are active for performance
|
||||
if (this.isDrawing) {
|
||||
// Use throttled partial update for active chunks only
|
||||
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
|
||||
} else {
|
||||
// This method is now simplified - we only update after drawing is complete
|
||||
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||
if (!this.isDrawing) {
|
||||
// Not drawing - do full update to show all chunks
|
||||
this.updateActiveMaskCanvas(true);
|
||||
}
|
||||
// During drawing, we don't update chunks at all - overlay handles preview
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1142,20 +1275,13 @@ export class MaskTool {
|
||||
|
||||
drawBrushPreview(viewCoords: Point): void {
|
||||
if (!this.previewVisible || this.isDrawing) {
|
||||
this.clearPreview();
|
||||
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPreview();
|
||||
const zoom = this.canvasInstance.viewport.zoom;
|
||||
const radius = (this.brushSize / 2) * zoom;
|
||||
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
|
||||
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
this.previewCtx.lineWidth = 1;
|
||||
this.previewCtx.setLineDash([2, 4]);
|
||||
this.previewCtx.stroke();
|
||||
// Use overlay canvas instead of preview canvas for brush cursor
|
||||
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||
}
|
||||
|
||||
clearPreview(): void {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.j
|
||||
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
||||
import { convertToImage } from "./utils/ImageUtils.js";
|
||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
|
||||
import type { ComfyNode } from './types';
|
||||
|
||||
const log = createModuleLogger('SAMDetectorIntegration');
|
||||
@@ -376,6 +377,9 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
||||
}
|
||||
|
||||
|
||||
// Store original onClipspaceEditorSave function to restore later
|
||||
let originalOnClipspaceEditorSave: (() => void) | null = null;
|
||||
|
||||
// Function to setup SAM Detector hook in menu options
|
||||
export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
||||
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||
@@ -408,9 +412,46 @@ export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
||||
node.imgs = [uploadResult.imageElement];
|
||||
(node as any).clipspaceImg = uploadResult.imageElement;
|
||||
|
||||
// Ensure proper clipspace structure for updated ComfyUI
|
||||
if (!ComfyApp.clipspace) {
|
||||
ComfyApp.clipspace = {};
|
||||
}
|
||||
|
||||
// Set up clipspace with proper indices
|
||||
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
|
||||
ComfyApp.clipspace.selectedIndex = 0;
|
||||
ComfyApp.clipspace.combinedIndex = 0;
|
||||
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||
|
||||
// Copy to ComfyUI clipspace
|
||||
ComfyApp.copyToClipspace(node);
|
||||
|
||||
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
|
||||
if (!originalOnClipspaceEditorSave) {
|
||||
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
|
||||
ComfyApp.onClipspaceEditorSave = function() {
|
||||
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
|
||||
|
||||
// Use the unified clipspace validation function
|
||||
const isValid = validateAndFixClipspace();
|
||||
if (!isValid) {
|
||||
log.error("Clipspace validation failed, cannot proceed with paste");
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the original function
|
||||
if (originalOnClipspaceEditorSave) {
|
||||
originalOnClipspaceEditorSave.call(ComfyApp);
|
||||
}
|
||||
|
||||
// Restore the original function after use
|
||||
if (originalOnClipspaceEditorSave) {
|
||||
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
|
||||
originalOnClipspaceEditorSave = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Start monitoring for SAM Detector results
|
||||
startSAMDetectorMonitoring(node);
|
||||
|
||||
|
||||
170
src/css/blend_mode_menu.css
Normal file
170
src/css/blend_mode_menu.css
Normal file
@@ -0,0 +1,170 @@
|
||||
/* Blend Mode Menu Styles */
|
||||
#blend-mode-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-title-bar {
|
||||
background: #3a3a3a;
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 3px 3px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-title-text {
|
||||
flex: 1;
|
||||
cursor: move;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-content {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-container {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-label {
|
||||
color: white;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-container {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option {
|
||||
padding: 5px 10px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option.active {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: none;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
margin: 2px;
|
||||
@@ -51,6 +51,32 @@
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
/* Crop mode button styling */
|
||||
.painter-button#crop-mode-btn {
|
||||
background-color: #444;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn.primary {
|
||||
background-color: #0080ff;
|
||||
border-color: #0070e0;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn.primary:hover {
|
||||
background-color: #1090ff;
|
||||
border-color: #0080ff;
|
||||
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn:hover {
|
||||
background-color: #555;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.painter-button.success {
|
||||
border-color: #4ae27a;
|
||||
background-color: #444;
|
||||
@@ -187,7 +213,7 @@
|
||||
border-radius: 5px;
|
||||
border: 1px solid #555;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
@@ -306,6 +332,25 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Disabled state for switch */
|
||||
.clipboard-switch.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
background: #3a3a3a !important; /* Override gradient */
|
||||
border-color: #4a4a4a !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.clipboard-switch.disabled .switch-knob {
|
||||
background-color: #4a4a4a !important;
|
||||
}
|
||||
|
||||
.clipboard-switch.disabled .switch-labels {
|
||||
color: #777 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.painter-separator {
|
||||
width: 1px;
|
||||
|
||||
230
src/css/layers_panel.css
Normal file
230
src/css/layers_panel.css
Normal file
@@ -0,0 +1,230 @@
|
||||
/* Layers Panel Styles */
|
||||
.layers-panel {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layers-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.layers-panel-title {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layers-panel-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layers-btn {
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.layers-btn:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.layers-btn:active {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layers-btn:disabled {
|
||||
background: #2a2a2a;
|
||||
color: #666666;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.layers-btn:disabled:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 4px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.layer-row.selected {
|
||||
background: #2d5aa0 !important;
|
||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||
}
|
||||
|
||||
.layer-row.dragging {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.layer-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layer-thumbnail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name.editing {
|
||||
background: #4a4a4a;
|
||||
border: 1px solid #6a6a6a;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drag-insertion-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #4a7bc8;
|
||||
border-radius: 1px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Icon container styles */
|
||||
.layers-panel .icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.visibility-hidden {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.visibility-hidden img {
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.fallback-text {
|
||||
font-size: 10px;
|
||||
color: #888888;
|
||||
}
|
||||
@@ -21,6 +21,13 @@ export interface Layer {
|
||||
flipH?: boolean;
|
||||
flipV?: boolean;
|
||||
blendArea?: number;
|
||||
cropMode?: boolean; // czy warstwa jest w trybie crop
|
||||
cropBounds?: { // granice przycinania
|
||||
x: number; // offset od lewej krawędzi obrazu
|
||||
y: number; // offset od górnej krawędzi obrazu
|
||||
width: number; // szerokość widocznego obszaru
|
||||
height: number; // wysokość widocznego obszaru
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComfyNode {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||
|
||||
// @ts-ignore
|
||||
import {api} from "../../../scripts/api.js";
|
||||
@@ -56,7 +57,13 @@ export class ClipboardManager {
|
||||
*/
|
||||
tryClipspacePaste = withErrorHandling(async (addMode: AddMode): Promise<boolean> => {
|
||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||
ComfyApp.pasteFromClipspace(this.canvas.node);
|
||||
|
||||
// Use the unified clipspace validation and paste function
|
||||
const pasteSuccess = safeClipspacePaste(this.canvas.node);
|
||||
if (!pasteSuccess) {
|
||||
log.debug("Safe clipspace paste failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||
const clipspaceImage = this.canvas.node.imgs[0];
|
||||
|
||||
114
src/utils/ClipspaceUtils.ts
Normal file
114
src/utils/ClipspaceUtils.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../../scripts/app.js";
|
||||
|
||||
const log = createModuleLogger('ClipspaceUtils');
|
||||
|
||||
/**
|
||||
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
|
||||
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
|
||||
*/
|
||||
export function validateAndFixClipspace(): boolean {
|
||||
log.debug("Validating and fixing clipspace structure");
|
||||
|
||||
// Check if clipspace exists
|
||||
if (!ComfyApp.clipspace) {
|
||||
log.debug("ComfyUI clipspace is not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate clipspace structure
|
||||
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
|
||||
log.debug("ComfyUI clipspace has no images");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug("Current clipspace state:", {
|
||||
hasImgs: !!ComfyApp.clipspace.imgs,
|
||||
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||
img_paste_mode: ComfyApp.clipspace.img_paste_mode
|
||||
});
|
||||
|
||||
// Ensure required indices are set
|
||||
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
|
||||
ComfyApp.clipspace.selectedIndex = 0;
|
||||
log.debug("Fixed clipspace selectedIndex to 0");
|
||||
}
|
||||
|
||||
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
|
||||
ComfyApp.clipspace.combinedIndex = 0;
|
||||
log.debug("Fixed clipspace combinedIndex to 0");
|
||||
}
|
||||
|
||||
if (!ComfyApp.clipspace.img_paste_mode) {
|
||||
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||
log.debug("Fixed clipspace img_paste_mode to 'selected'");
|
||||
}
|
||||
|
||||
// Ensure indices are within bounds
|
||||
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
|
||||
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
|
||||
ComfyApp.clipspace.selectedIndex = maxIndex;
|
||||
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
|
||||
}
|
||||
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
|
||||
ComfyApp.clipspace.combinedIndex = maxIndex;
|
||||
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
|
||||
}
|
||||
|
||||
// Verify the image at combinedIndex exists and has src
|
||||
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||
if (!combinedImg || !combinedImg.src) {
|
||||
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
|
||||
// Try to use the first available image
|
||||
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
|
||||
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
|
||||
ComfyApp.clipspace.combinedIndex = i;
|
||||
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Final check - if still no valid image found
|
||||
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||
if (!finalImg || !finalImg.src) {
|
||||
log.error("No valid images found in clipspace after attempting fixes");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Final clipspace structure:", {
|
||||
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
|
||||
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
|
||||
* @param {any} node - The ComfyUI node to paste to
|
||||
* @returns {boolean} - True if paste was successful, false otherwise
|
||||
*/
|
||||
export function safeClipspacePaste(node: any): boolean {
|
||||
log.debug("Attempting safe clipspace paste");
|
||||
|
||||
if (!validateAndFixClipspace()) {
|
||||
log.debug("Clipspace validation failed, cannot paste");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
ComfyApp.pasteFromClipspace(node);
|
||||
log.debug("Successfully called pasteFromClipspace");
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error("Error calling pasteFromClipspace:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export const LAYERFORGE_TOOLS = {
|
||||
DELETE: 'delete',
|
||||
DUPLICATE: 'duplicate',
|
||||
BLEND_MODE: 'blend_mode',
|
||||
OPACITY: 'opacity',
|
||||
OPACITY: 'opacity',
|
||||
MASK: 'mask',
|
||||
BRUSH: 'brush',
|
||||
ERASER: 'eraser',
|
||||
@@ -21,16 +21,22 @@ export const LAYERFORGE_TOOLS = {
|
||||
SETTINGS: 'settings',
|
||||
SYSTEM_CLIPBOARD: 'system_clipboard',
|
||||
CLIPSPACE: 'clipspace',
|
||||
CROP: 'crop',
|
||||
TRANSFORM: 'transform',
|
||||
} as const;
|
||||
|
||||
// SVG Icons for LayerForge tools
|
||||
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
|
||||
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
|
||||
const CROP_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 15h3V7c0-1.1-.9-2-2-2H10v3h7v7zM7 18V1H4v4H0v3h4v10c0 2 1 3 3 3h10v4h3v-4h4v-3H24z"/></svg>';
|
||||
const TRANSFORM_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M11.3 17.096c.092-.044.34-.052 1.028-.044l.912.008.124.124c.184.184.184.408.004.584l-.128.132-.896.012c-.72.008-.924 0-1.036-.048-.18-.072-.284-.264-.256-.452.028-.168.092-.248.248-.316Zm-3.164 0c.096-.044.328-.052 1.036-.044l.916.008.116.132c.16.18.16.396 0 .576l-.116.132-.876.012c-.552.008-.928-.004-1.02-.032-.388-.112-.428-.62-.056-.784Zm-4.6-1.168.112-.096 1.42.004 1.424.004.116.116.116.116V17.48v1.408l-.116.116-.116.116H5.068h-1.42l-.112-.096-.112-.096L3.42 17.48V16.032l.112-.096ZM4.78 12.336c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.964.964l-.116.128c-.1.112-.144.132-.304.132s-.204-.02-.304-.132L4.644 14.4l-.004-.964v-.964l.136-.136Zm8.868-.648c-.008-.024-.004-.048.008-.048s1.504.512 3.312 1.136c1.812.624 4.252 1.464 5.424 1.868 1.168.404 2.128.744 2.128.76 0 .012-.24.108-.528.212-.292.104-1.468.52-2.616.928l-2.08.74-.936 2.62c-.512 1.44-.944 2.616-.956 2.616-.016 0-.86-2.424-1.88-5.392-1.02-2.964-1.864-5.412-1.876-5.44ZM19.292 9.08c.216-.088.432-.02.548.168.076.124.08.188.072 1.06l-.012.928-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12-.012-.928c-.008-.872-.004-.936.072-1.06.044-.072.12-.148.172-.168Zm-14.516.096c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.956c0 1.064-.004 1.088-.268 1.2-.18.072-.376.012-.492-.148-.076-.104-.08-.172-.08-1.06V9.312l.136-.136ZM19.192 6c.096-.088.168-.116.288-.116s.192.028.288.116l.132.116V7.1v.98l-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12V7.096 6.112l.132-.116ZM4.816 5.964c.048-.044.152-.072.256-.072.144 0 .196.02.292.124l.116.124v.98.968l-.116.116c-.092.092-.152.116-.284.116-.408 0-.44-.28-.44-1.22s.012-1.016.176-1.148Zm9.516-3.192.14-.136.968.004h.968l.112.116c.152.152.188.3.108.468-.124.252-.196.276-1.044.288-.42.008-.84.004-.936-.012-.24-.036-.38-.192-.436-.408-.02-.156-.008-.184.12-.312Zm-3.156-.268.136.136h.956c1.064 0 1.088.004 1.2.268.072.172.016.372-.136.492-.096.076-.16.08-1.06.08h-.96l-.136-.136c-.104-.104-.136-.168-.136-.284s.032-.18.136-.284Zm-3.16 0 .136.136h.96c.94 0 .964.004 1.068.088.2.176.196.508-.004.668-.1.08-.156.084-1.064.084h-.96l-.136-.136c-.188-.188-.188-.38 0-.568Zm10.04-1.14c.044-.02.712-.032 1.476-.028l1.396.008.096.112.096.112v1.424 1.5l-.116.116-.116.116L19.48 4.72H18.072l-.116-.116-.116-.116V3.072c0-1.524.004-1.544.216-1.632ZM3.62 1.456c.184-.08 2.74-.08 2.896 0 .196.104.204.164.204 1.604s-.008 1.5-.204 1.604c-.148.076-2.732.084-2.896.008-.212-.096-.22-.148-.22-1.608s.008-1.516.22-1.608Z"/></svg>';
|
||||
|
||||
|
||||
const LAYERFORGE_TOOL_ICONS = {
|
||||
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.CROP]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CROP_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.TRANSFORM]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRANSFORM_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
|
||||
@@ -72,7 +78,9 @@ const LAYERFORGE_TOOL_COLORS = {
|
||||
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
||||
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
||||
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
|
||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
|
||||
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
|
||||
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
|
||||
};
|
||||
|
||||
export interface IconCache {
|
||||
|
||||
Reference in New Issue
Block a user