mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11dd554204 | ||
|
|
9f21ff13ae | ||
|
|
1a1d8748cb | ||
|
|
38973b4698 | ||
|
|
1bd261bee0 | ||
|
|
df6979a59b | ||
|
|
2427f0bc5f | ||
|
|
3356c631bb | ||
|
|
3d34bfafd5 | ||
|
|
3c3e6934d7 | ||
|
|
84e1e4820c | ||
|
|
012368c52b | ||
|
|
82c42f99fe | ||
|
|
5da0855a52 | ||
|
|
ed9fdf5d60 | ||
|
|
d84b9385ad | ||
|
|
c4318d4923 | ||
|
|
5b54ab28cb | ||
|
|
503ec126a5 | ||
|
|
3d6e3901d0 | ||
|
|
4df89a793e | ||
|
|
e42e08e35d | ||
|
|
7ed6f7ee93 |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "layerforge",
|
||||
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
|
||||
"Node name for S&R": "CanvasNode",
|
||||
"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.0 MiB After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB 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": "layerforge",
|
||||
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
|
||||
"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: 939 KiB 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';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -245,6 +245,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;
|
||||
@@ -363,6 +371,8 @@ 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) {
|
||||
@@ -539,7 +549,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') {
|
||||
@@ -692,12 +705,8 @@ 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)
|
||||
@@ -707,43 +716,139 @@ export class CanvasInteractions {
|
||||
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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -431,39 +431,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();
|
||||
}
|
||||
|
||||
153
js/CanvasView.js
153
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",
|
||||
@@ -629,19 +703,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 +1014,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 +1048,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.");
|
||||
|
||||
@@ -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.1"
|
||||
version = "1.5.4"
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -316,6 +316,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;
|
||||
@@ -445,6 +455,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +639,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};
|
||||
|
||||
@@ -797,66 +813,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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
|
||||
@@ -532,46 +532,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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -672,18 +791,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 +1165,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 +1204,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.");
|
||||
|
||||
@@ -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