21 Commits

Author SHA1 Message Date
Dariusz L
1a1d8748cb Update pyproject.toml 2025-08-04 01:50:29 +02:00
Dariusz L
38973b4698 Rename CanvasNode to LayerForgeNode
Replaced all instances of CanvasNode with LayerForgeNode to prevent naming conflicts with the ComfyUI-YCanvas node.
2025-08-04 01:49:37 +02:00
Dariusz L
1bd261bee0 Adjust Style disabled buttons 2025-08-04 01:00:35 +02:00
Dariusz L
df6979a59b Fix selection border points for vertical/horizontal flip 2025-08-04 00:46:14 +02:00
Dariusz L
2427f0bc5f Add fallback instructions to error for node confirmation failure 2025-08-03 23:08:52 +02:00
Dariusz L
3356c631bb Fix toggle mask switch UI sync with auto refresh
Ensured the toggle mask switch UI stays in sync with mask visibility when auto_refresh_after_generation hides or shows the mask. The checkbox and switch now correctly reflect the current mask state, preventing UI desynchronization and improving user experience.
2025-08-03 22:25:25 +02:00
Dariusz L
3d34bfafd5 Fix matting: refresh image after background removal
Fixed an issue where images were not immediately refreshed after background removal (matting). Now, the canvas updates instantly when the background is removed, ensuring correct display without requiring manual scaling or other actions.
2025-08-03 22:14:39 +02:00
Dariusz L
3c3e6934d7 Refactor CanvasLayers.ts: unify & deduplicate logic
Refactored CanvasLayers.ts to eliminate code duplication by unifying five main areas into reusable functions, following the DRY principle. Improved code readability, maintainability, and flexibility with better naming, documentation, and parameterization.
2025-08-03 21:57:47 +02:00
Dariusz L
84e1e4820c Improve cache selection for scaling with blend & crop
Enhanced the system to always select the best available cache based on both blend area and crop, prioritizing exact matches. Prevented costly operations and live rendering during scaling for optimal performance and smooth user experience.
2025-08-03 21:01:46 +02:00
Dariusz L
012368c52b Revert Cached Blend Area 2025-08-03 18:20:41 +02:00
Dariusz L
82c42f99fe Fix clipboard switch tooltip to update on toggle
Refactored tooltip logic for the clipboard switch so it now updates immediately when toggled, showing the correct template without requiring mouse movement. Added helper functions and improved event handling for better UX.
2025-08-03 14:56:18 +02:00
Dariusz L
5da0855a52 Added tooltip to mask visibility switch 2025-08-03 14:38:40 +02:00
Dariusz L
ed9fdf5d60 disable delete button when no layers selected
Added updateButtonStates() to enable/disable delete button based on selection
Updated control setup and selection handlers to call this method
Added CSS for disabled button state and tooltip
Delete button now disables when no layers are selected; all other panel features unchanged
2025-08-03 14:33:20 +02:00
Dariusz L
d84b9385ad Refactor: Move CanvasLayersPanel inline styles to external CSS
Moved all inline styles from CanvasLayersPanel.ts to layers_panel.css
Updated TypeScript to load external CSS and removed injectStyles()
Replaced inline styles with CSS classes in UI methods
Ensured all panel features and interactions work as before
Improved code maintainability and consistency with project structure
2025-08-03 14:27:31 +02:00
Dariusz L
c4318d4923 Refactor: Move blend mode menu styles to CSS file
Moved all blend mode menu styles from CanvasLayers.ts to a dedicated CSS file. Replaced inline styles with CSS classes and preserved all functionality.
2025-08-03 14:18:21 +02:00
Dariusz L
5b54ab28cb Update pyproject.toml 2025-08-03 02:45:20 +02:00
Dariusz L
503ec126a5 Fix DataCloneError by excluding non-serializable cache from state
Excluded blendedImageCache and blendedImageDirty properties from layer serialization in CanvasState.ts to prevent DataCloneError when saving state. This ensures that only serializable data is sent to Web Workers, while runtime caches are regenerated as needed. Blend area performance optimization remains functional without serialization issues.
2025-08-03 02:43:30 +02:00
Dariusz L
3d6e3901d0 Fix button crop icon display and update functionality 2025-08-03 02:19:52 +02:00
Dariusz L
4df89a793e Fix layer selection bug by sorting hit-test by z-index 2025-08-02 19:52:08 +02:00
Dariusz L
e42e08e35d Crop mode button to switch 2025-08-02 19:43:03 +02:00
Dariusz L
7ed6f7ee93 Implement crop mode for cropping selected layer 2025-08-02 19:05:11 +02:00
31 changed files with 3483 additions and 1216 deletions

View File

@@ -4,16 +4,16 @@ import os
# Add the custom node's directory to the Python path # Add the custom node's directory to the Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 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 = { NODE_CLASS_MAPPINGS = {
"CanvasNode": CanvasNode "LayerForgeNode": LayerForgeNode
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"CanvasNode": "Layer Forge (Editor, outpaintintg, Canvas Node)" "LayerForgeNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
} }
WEB_DIRECTORY = "./js" WEB_DIRECTORY = "./js"

View File

@@ -90,7 +90,7 @@ class BiRefNet(torch.nn.Module):
return [output] return [output]
class CanvasNode: class LayerForgeNode:
_canvas_data_storage = {} _canvas_data_storage = {}
_storage_lock = threading.Lock() _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}") log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
raise raise
CanvasNode.setup_routes()
NODE_CLASS_MAPPINGS = {
"CanvasNode": CanvasNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CanvasNode": "LayerForge"
}

View File

@@ -1,8 +1,8 @@
{ {
"id": "d26732fd-91ea-4503-8d0d-383544823cec", "id": "d26732fd-91ea-4503-8d0d-383544823cec",
"revision": 0, "revision": 0,
"last_node_id": 49, "last_node_id": 52,
"last_link_id": 112, "last_link_id": 114,
"nodes": [ "nodes": [
{ {
"id": 7, "id": 7,
@@ -18,7 +18,7 @@
"flags": { "flags": {
"collapsed": true "collapsed": true
}, },
"order": 6, "order": 8,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -62,7 +62,7 @@
58 58
], ],
"flags": {}, "flags": {},
"order": 8, "order": 10,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -103,7 +103,7 @@
26 26
], ],
"flags": {}, "flags": {},
"order": 7, "order": 9,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -260,7 +260,7 @@
46 46
], ],
"flags": {}, "flags": {},
"order": 12, "order": 14,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -304,7 +304,7 @@
58 58
], ],
"flags": {}, "flags": {},
"order": 10, "order": 12,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -344,7 +344,7 @@
138 138
], ],
"flags": {}, "flags": {},
"order": 9, "order": 11,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -365,12 +365,12 @@
{ {
"name": "pixels", "name": "pixels",
"type": "IMAGE", "type": "IMAGE",
"link": 106 "link": 113
}, },
{ {
"name": "mask", "name": "mask",
"type": "MASK", "type": "MASK",
"link": 107 "link": 114
} }
], ],
"outputs": [ "outputs": [
@@ -421,7 +421,7 @@
262 262
], ],
"flags": {}, "flags": {},
"order": 11, "order": 13,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -462,7 +462,7 @@
"widget_ue_connectable": {} "widget_ue_connectable": {}
}, },
"widgets_values": [ "widgets_values": [
858769863184862, 1006953529460557,
"randomize", "randomize",
20, 20,
1, 1,
@@ -526,7 +526,7 @@
893.8499755859375 893.8499755859375
], ],
"flags": {}, "flags": {},
"order": 13, "order": 15,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -550,15 +550,15 @@
"id": 23, "id": 23,
"type": "CLIPTextEncode", "type": "CLIPTextEncode",
"pos": [ "pos": [
-835.4583129882812, -905.195556640625,
878.8148193359375 924.5140991210938
], ],
"size": [ "size": [
311.0955810546875, 311.0955810546875,
108.43277740478516 108.43277740478516
], ],
"flags": {}, "flags": {},
"order": 5, "order": 7,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -591,48 +591,94 @@
"bgcolor": "#353" "bgcolor": "#353"
}, },
{ {
"id": 48, "id": 51,
"type": "CanvasNode", "type": "Note",
"pos": [ "pos": [
-514.2837524414062, -916.8970947265625,
543.1272583007812 476.72564697265625
], ],
"size": [ "size": [
1862.893798828125, 350.92510986328125,
1237.79638671875 250.50831604003906
], ],
"flags": {}, "flags": {},
"order": 4, "order": 4,
"mode": 0, "mode": 0,
"inputs": [], "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": [ "outputs": [
{ {
"name": "image", "name": "image",
"type": "IMAGE", "type": "IMAGE",
"links": [ "links": [
106 113
] ]
}, },
{ {
"name": "mask", "name": "mask",
"type": "MASK", "type": "MASK",
"links": [ "links": [
107 114
] ]
} }
], ],
"properties": { "properties": {
"cnr_id": "layerforge", "cnr_id": "layerforge",
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616", "ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
"Node name for S&R": "CanvasNode", "Node name for S&R": "LayerForgeNode",
"widget_ue_connectable": {} "widget_ue_connectable": {}
}, },
"widgets_values": [ "widgets_values": [
false, false,
false, false,
true, true,
963, 18,
"48", "50",
"" ""
] ]
} }
@@ -734,22 +780,6 @@
0, 0,
"IMAGE" "IMAGE"
], ],
[
106,
48,
0,
38,
3,
"IMAGE"
],
[
107,
48,
1,
38,
4,
"MASK"
],
[ [
110, 110,
38, 38,
@@ -773,6 +803,22 @@
8, 8,
0, 0,
"LATENT" "LATENT"
],
[
113,
50,
0,
38,
3,
"IMAGE"
],
[
114,
50,
1,
38,
4,
"MASK"
] ]
], ],
"groups": [], "groups": [],
@@ -781,8 +827,8 @@
"ds": { "ds": {
"scale": 0.6588450000000008, "scale": 0.6588450000000008,
"offset": [ "offset": [
1318.77716124466, 1117.7398801488407,
-32.39290875553955 -110.40634975151642
] ]
}, },
"ue_links": [], "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

View File

@@ -1,19 +1,137 @@
{ {
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8", "id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
"revision": 0, "revision": 0,
"last_node_id": 707, "last_node_id": 710,
"last_link_id": 1499, "last_link_id": 1505,
"nodes": [ "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, "id": 369,
"type": "PreviewImage", "type": "PreviewImage",
"pos": [ "pos": [
-1699.1021728515625, -1914.3177490234375,
-3355.60498046875 -2807.92724609375
], ],
"size": [ "size": [
660.91162109375, 710,
400.2092590332031 450
], ],
"flags": {}, "flags": {},
"order": 6, "order": 6,
@@ -38,21 +156,21 @@
"id": 606, "id": 606,
"type": "PreviewImage", "type": "PreviewImage",
"pos": [ "pos": [
-1911.126708984375, -1913.4202880859375,
-2916.072998046875 -3428.773193359375
], ],
"size": [ "size": [
551.7399291992188, 700,
546.8018798828125 510
], ],
"flags": {}, "flags": {},
"order": 1, "order": 3,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
"name": "images", "name": "images",
"type": "IMAGE", "type": "IMAGE",
"link": 1495 "link": 1503
} }
], ],
"outputs": [], "outputs": [],
@@ -64,92 +182,30 @@
}, },
"widgets_values": [] "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, "id": 442,
"type": "JoinImageWithAlpha", "type": "JoinImageWithAlpha",
"pos": [ "pos": [
-1902.5858154296875, -1190.1787109375,
-3187.159423828125 -3237.75732421875
], ],
"size": [ "size": [
176.86483764648438, 176.86483764648438,
46 46
], ],
"flags": {}, "flags": {},
"order": 2, "order": 5,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
"name": "image", "name": "image",
"type": "IMAGE", "type": "IMAGE",
"link": 1494 "link": 1502
}, },
{ {
"name": "alpha", "name": "alpha",
"type": "MASK", "type": "MASK",
"link": 1497 "link": 1505
} }
], ],
"outputs": [ "outputs": [
@@ -170,25 +226,87 @@
}, },
"widgets_values": [] "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, "id": 706,
"type": "MaskToImage", "type": "MaskToImage",
"pos": [ "pos": [
-1901.433349609375, -1911.38525390625,
-3332.2021484375 -2875.74658203125
], ],
"size": [ "size": [
184.62362670898438, 184.62362670898438,
26 26
], ],
"flags": {}, "flags": {},
"order": 3, "order": 4,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
"name": "mask", "name": "mask",
"type": "MASK", "type": "MASK",
"link": 1498 "link": 1504
} }
], ],
"outputs": [ "outputs": [
@@ -203,57 +321,10 @@
"properties": { "properties": {
"cnr_id": "comfy-core", "cnr_id": "comfy-core",
"ver": "0.3.44", "ver": "0.3.44",
"widget_ue_connectable": {}, "Node name for S&R": "MaskToImage",
"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",
"widget_ue_connectable": {} "widget_ue_connectable": {}
}, },
"widgets_values": [ "widgets_values": []
true,
false,
"697",
15,
"697",
""
]
} }
], ],
"links": [ "links": [
@@ -273,38 +344,6 @@
0, 0,
"IMAGE" "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, 1499,
706, 706,
@@ -312,16 +351,64 @@
369, 369,
0, 0,
"IMAGE" "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": [], "groups": [],
"config": {}, "config": {},
"extra": { "extra": {
"ds": { "ds": {
"scale": 0.9646149645000008, "scale": 0.7972024500000005,
"offset": [ "offset": [
3002.5649125522764, 3208.3419155969927,
3543.443319064718 3617.011371212156
] ]
}, },
"ue_links": [], "ue_links": [],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 939 KiB

After

Width:  |  Height:  |  Size: 854 KiB

View File

@@ -123,10 +123,14 @@ export class BatchPreviewManager {
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible; this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
if (this.maskWasVisible) { if (this.maskWasVisible) {
this.canvas.maskTool.toggleOverlayVisibility(); this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`); const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
if (toggleBtn) { if (toggleSwitch) {
toggleBtn.classList.remove('primary'); const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
const iconContainer = toggleBtn.querySelector('.mask-icon-container'); if (checkbox) {
checkbox.checked = false;
}
toggleSwitch.classList.remove('primary');
const iconContainer = toggleSwitch.querySelector('.switch-icon');
if (iconContainer) { if (iconContainer) {
iconContainer.style.opacity = '0.5'; iconContainer.style.opacity = '0.5';
} }
@@ -165,10 +169,14 @@ export class BatchPreviewManager {
this.canvas.render(); this.canvas.render();
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) { if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
this.canvas.maskTool.toggleOverlayVisibility(); this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`); const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
if (toggleBtn) { if (toggleSwitch) {
toggleBtn.classList.add('primary'); const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
const iconContainer = toggleBtn.querySelector('.mask-icon-container'); if (checkbox) {
checkbox.checked = true;
}
toggleSwitch.classList.add('primary');
const iconContainer = toggleSwitch.querySelector('.switch-icon');
if (iconContainer) { if (iconContainer) {
iconContainer.style.opacity = '1'; iconContainer.style.opacity = '1';
} }

View File

@@ -238,7 +238,10 @@ export class CanvasIO {
} }
catch (error) { catch (error) {
log.error(`Failed to send data for node ${nodeId}:`, 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) { async addInputToCanvas(inputImage, inputMask) {

View File

@@ -245,6 +245,14 @@ export class CanvasInteractions {
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) { if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
this.logDragCompletion(coords); this.logDragCompletion(coords);
} }
// 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) // Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode); const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag; const duplicatedInDrag = this.interaction.hasClonedInDrag;
@@ -363,6 +371,8 @@ export class CanvasInteractions {
layer.height *= scaleFactor; layer.height *= scaleFactor;
layer.x += (oldWidth - layer.width) / 2; layer.x += (oldWidth - layer.width) / 2;
layer.y += (oldHeight - layer.height) / 2; layer.y += (oldHeight - layer.height) / 2;
// Handle wheel scaling end for layers with blend area
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
} }
} }
calculateGridBasedScaling(oldHeight, deltaY) { calculateGridBasedScaling(oldHeight, deltaY) {
@@ -539,7 +549,10 @@ export class CanvasInteractions {
width: layer.width, height: layer.height, width: layer.width, height: layer.height,
rotation: layer.rotation, rotation: layer.rotation,
centerX: layer.x + layer.width / 2, 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 }; this.interaction.dragStart = { ...worldCoords };
if (handle === 'rot') { if (handle === 'rot') {
@@ -692,12 +705,8 @@ export class CanvasInteractions {
let mouseY = worldCoords.y; let mouseY = worldCoords.y;
if (this.interaction.isCtrlPressed) { if (this.interaction.isCtrlPressed) {
const snapThreshold = 10 / this.canvas.viewport.zoom; const snapThreshold = 10 / this.canvas.viewport.zoom;
const snappedMouseX = snapToGrid(mouseX); mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
mouseX = snappedMouseX;
const snappedMouseY = snapToGrid(mouseY);
if (Math.abs(mouseY - snappedMouseY) < snapThreshold)
mouseY = snappedMouseY;
} }
const o = this.interaction.transformOrigin; const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) 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 rad = o.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
// Vector from anchor to mouse
const vecX = mouseX - anchor.x; const vecX = mouseX - anchor.x;
const vecY = mouseY - anchor.y; const vecY = mouseY - anchor.y;
let newWidth = vecX * cos + vecY * sin; // Rotate vector to align with layer's local coordinates
let newHeight = vecY * cos - vecX * sin; let localVecX = vecX * cos + vecY * sin;
if (isShiftPressed) { let localVecY = vecY * cos - vecX * sin;
const originalAspectRatio = o.width / o.height; // Determine sign based on handle
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
} localVecX *= signX;
else { localVecY *= signY;
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; // If not a corner handle, keep original dimension
}
}
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) if (signX === 0)
newWidth = o.width; localVecX = o.width;
if (signY === 0) if (signY === 0)
newHeight = o.height; localVecY = o.height;
if (newWidth < 10) if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
newWidth = 10; // CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
if (newHeight < 10) // Calculate mouse movement since drag start, in the layer's local coordinate system.
newHeight = 10; const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
layer.width = newWidth; const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
layer.height = newHeight; const mouseX_local = mouseX - (o.centerX ?? 0);
const deltaW = newWidth - o.width; const mouseY_local = mouseY - (o.centerY ?? 0);
const deltaH = newHeight - o.height; // Rotate mouse delta into the layer's unrotated frame
const shiftX = (deltaW / 2) * signX; const deltaX_world = mouseX_local - dragStartX_local;
const shiftY = (deltaH / 2) * signY; const deltaY_world = mouseY_local - dragStartY_local;
const worldShiftX = shiftX * cos - shiftY * sin; let mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
const worldShiftY = shiftX * sin + shiftY * cos; let mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
const newCenterX = o.centerX + worldShiftX; if (layer.flipH) {
const newCenterY = o.centerY + worldShiftY; mouseDeltaX_local *= -1;
layer.x = newCenterX - layer.width / 2; }
layer.y = newCenterY - layer.height / 2; 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(); this.canvas.render();
} }
rotateLayerFromHandle(worldCoords, isShiftPressed) { rotateLayerFromHandle(worldCoords, isShiftPressed) {

View File

@@ -3,6 +3,7 @@ import { createModuleLogger } from "./utils/LoggerUtils.js";
import { generateUUID, generateUniqueFileName, createCanvas } from "./utils/CommonUtils.js"; import { generateUUID, generateUniqueFileName, createCanvas } from "./utils/CommonUtils.js";
import { withErrorHandling, createValidationError } from "./ErrorHandler.js"; import { withErrorHandling, createValidationError } from "./ErrorHandler.js";
import { showErrorNotification } from "./utils/NotificationUtils.js"; import { showErrorNotification } from "./utils/NotificationUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
// @ts-ignore // @ts-ignore
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
// @ts-ignore // @ts-ignore
@@ -12,9 +13,21 @@ import { createDistanceFieldMaskSync } from "./utils/ImageAnalysis.js";
const log = createModuleLogger('CanvasLayers'); const log = createModuleLogger('CanvasLayers');
export class CanvasLayers { export class CanvasLayers {
constructor(canvas) { constructor(canvas) {
this._canvasMaskCache = new Map();
this.blendMenuElement = null; this.blendMenuElement = null;
this.blendMenuWorldX = 0; this.blendMenuWorldX = 0;
this.blendMenuWorldY = 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) => { this.addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => {
if (!image) { if (!image) {
throw createValidationError("Image is required for layer creation"); throw createValidationError("Image is required for layer creation");
@@ -102,6 +115,8 @@ export class CanvasLayers {
this.canvas = canvas; this.canvas = canvas;
this.clipboardManager = new ClipboardManager(canvas); this.clipboardManager = new ClipboardManager(canvas);
this.distanceFieldCache = new WeakMap(); this.distanceFieldCache = new WeakMap();
this.processedImageCache = new Map();
this.processedImageDebounceTimers = new Map();
this.blendModes = [ this.blendModes = [
{ name: 'normal', label: 'Normal' }, { name: 'normal', label: 'Normal' },
{ name: 'multiply', label: 'Multiply' }, { name: 'multiply', label: 'Multiply' },
@@ -121,6 +136,8 @@ export class CanvasLayers {
this.isAdjustingOpacity = false; this.isAdjustingOpacity = false;
this.internalClipboard = []; this.internalClipboard = [];
this.clipboardPreference = 'system'; this.clipboardPreference = 'system';
// Load CSS for blend mode menu
addStylesheet(getUrl('./css/blend_mode_menu.css'));
} }
async copySelectedLayers() { async copySelectedLayers() {
if (this.canvas.canvasSelection.selectedLayers.length === 0) if (this.canvas.canvasSelection.selectedLayers.length === 0)
@@ -309,6 +326,10 @@ export class CanvasLayers {
this.canvas.canvasSelection.selectedLayers.forEach((layer) => { this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.width *= scale; layer.width *= scale;
layer.height *= 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.render();
this.canvas.requestSaveState(); this.canvas.requestSaveState();
@@ -323,6 +344,8 @@ export class CanvasLayers {
this.canvas.requestSaveState(); this.canvas.requestSaveState();
} }
getLayerAtPosition(worldX, worldY) { 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--) { for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
const layer = this.canvas.layers[i]; const layer = this.canvas.layers[i];
// Skip invisible layers // Skip invisible layers
@@ -361,73 +384,551 @@ export class CanvasLayers {
} }
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
// Check if we need to apply blend area effect
const blendArea = layer.blendArea ?? 0; const blendArea = layer.blendArea ?? 0;
const needsBlendAreaEffect = blendArea > 0; const needsBlendAreaEffect = blendArea > 0;
if (needsBlendAreaEffect) { // Check if we should render blend area live only in specific cases:
log.debug(`Applying blend area effect for layer ${layer.id}, blendArea: ${blendArea}%`); // 1. When user is actively resizing in crop mode (transforming crop bounds) - only for the specific layer being transformed
// Get or create distance field mask // 2. When user is actively resizing in transform mode (scaling layer) - only for the specific layer being transformed
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea); // 3. When blend area slider is being adjusted - only for the layer that has the menu open
if (maskCanvas) { // 4. When layer is in the transforming crop bounds set (continues live rendering until cache is ready)
// Create a temporary canvas for the masked layer // 5. When layer is in the transforming scale set (continues live rendering until cache is ready)
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height); const isTransformingCropBounds = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' &&
if (tempCtx) { this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id &&
// Draw the original image layer.cropMode;
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); // Check if user is actively scaling this layer in transform mode (not crop mode)
// Apply the distance field mask using destination-in for transparency effect const isTransformingScale = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' &&
tempCtx.globalCompositeOperation = 'destination-in'; this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id &&
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height); !layer.cropMode;
// Draw the result // Check if this specific layer is the one being adjusted in blend area slider
ctx.globalCompositeOperation = layer.blendMode || 'normal'; const isThisLayerBeingAdjusted = this.layersAdjustingBlendArea.has(layer.id);
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; // Check if this layer is in the transforming crop bounds set (continues live rendering until cache is ready)
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height); const isTransformingCropBoundsSet = this.layersTransformingCropBounds.has(layer.id);
} // Check if this layer is in the transforming scale set (continues live rendering until cache is ready)
else { const isTransformingScaleSet = this.layersTransformingScale.has(layer.id);
// Fallback to normal drawing // Check if this layer is being scaled by wheel or buttons (continues live rendering until cache is ready)
ctx.globalCompositeOperation = layer.blendMode || 'normal'; const isWheelScaling = this.layersWheelScaling.has(layer.id);
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; const shouldRenderLive = isTransformingCropBounds || isTransformingScale || isThisLayerBeingAdjusted || isTransformingCropBoundsSet || isTransformingScaleSet || isWheelScaling;
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); // 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 { if (bestMatchingCache) {
// Fallback to normal drawing log.debug(`Using best matching cache for layer ${layer.id} during scaling`);
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 (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 { else {
// Normal drawing without blend area effect // Normal drawing without blend area effect
ctx.globalCompositeOperation = layer.blendMode || 'normal'; this._drawLayerImage(ctx, layer);
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
} }
ctx.restore(); ctx.restore();
} }
getDistanceFieldMaskSync(image, blendArea) { /**
// Check cache first * Zunifikowana funkcja do rysowania obrazu warstwy z crop
let imageCache = this.distanceFieldCache.get(image); * @param ctx Canvas context
if (!imageCache) { * @param layer Warstwa do narysowania
imageCache = new Map(); * @param offsetX Przesunięcie X względem środka warstwy (domyślnie -width/2)
this.distanceFieldCache.set(image, imageCache); * @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); // Calculate the on-screen scale of the layer's transform frame
if (!maskCanvas) { 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 { try {
log.info(`Creating distance field mask for blendArea: ${blendArea}%`); const processedImage = this.createProcessedImage(layer);
maskCanvas = createDistanceFieldMaskSync(image, blendArea); if (processedImage) {
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`); this.processedImageCache.set(cacheKey, processedImage);
imageCache.set(blendArea, maskCanvas); log.debug(`Cached debounced processed image for layer ${layer.id}`);
// Trigger re-render to show the processed image
this.canvas.render();
}
} }
catch (error) { 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; return null;
} }
} }
else { 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 = {}) { _drawLayers(ctx, layers, options = {}) {
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex); const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
@@ -460,17 +961,13 @@ export class CanvasLayers {
} }
async getLayerImageData(layer) { async getLayerImageData(layer) {
try { 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) if (!tempCtx)
throw new Error("Could not create canvas context"); 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 // Use original image directly to ensure full quality
// by creating a temporary layer object for drawing. tempCtx.drawImage(layer.image, 0, 0, width, height);
const layerToDraw = {
...layer,
x: 0,
y: 0,
};
this._drawLayer(tempCtx, layerToDraw);
const dataUrl = tempCanvas.toDataURL('image/png'); const dataUrl = tempCanvas.toDataURL('image/png');
if (!dataUrl.startsWith('data:image/png;base64,')) { if (!dataUrl.startsWith('data:image/png;base64,')) {
throw new Error("Invalid image data format"); throw new Error("Invalid image data format");
@@ -527,30 +1024,54 @@ export class CanvasLayers {
this.canvas.saveState(); this.canvas.saveState();
} }
getHandles(layer) { getHandles(layer) {
const centerX = layer.x + layer.width / 2; const layerCenterX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2; const layerCenterY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180; const rad = layer.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
const halfW = layer.width / 2; let handleCenterX, handleCenterY, halfW, halfH;
const halfH = layer.height / 2; 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 = { const localHandles = {
'n': { x: 0, y: -halfH }, 'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH },
'ne': { x: halfW, y: -halfH }, 'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH },
'e': { x: halfW, y: 0 }, 's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH },
'se': { x: halfW, y: halfH }, 'w': { x: -halfW, y: 0 }, 'nw': { 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 } 'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
}; };
const worldHandles = {}; const worldHandles = {};
for (const key in localHandles) { for (const key in localHandles) {
const p = localHandles[key]; const p = localHandles[key];
worldHandles[key] = { worldHandles[key] = {
x: centerX + (p.x * cos - p.y * sin), x: handleCenterX + (p.x * cos - p.y * sin),
y: centerY + (p.x * sin + p.y * cos) y: handleCenterY + (p.x * sin + p.y * cos)
}; };
} }
return worldHandles; return worldHandles;
@@ -633,65 +1154,14 @@ export class CanvasLayers {
const menu = document.createElement('div'); const menu = document.createElement('div');
this.blendMenuElement = menu; this.blendMenuElement = menu;
menu.id = 'blend-mode-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'); const titleBar = document.createElement('div');
titleBar.style.cssText = ` titleBar.className = '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;
`;
const titleText = document.createElement('span'); const titleText = document.createElement('span');
titleText.textContent = `Blend Mode: ${selectedLayer.name}`; titleText.textContent = `Blend Mode: ${selectedLayer.name}`;
titleText.style.cssText = ` titleText.className = 'blend-menu-title-text';
flex: 1;
cursor: move;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const closeButton = document.createElement('button'); const closeButton = document.createElement('button');
closeButton.textContent = '×'; closeButton.textContent = '×';
closeButton.style.cssText = ` closeButton.className = '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;
`;
closeButton.onmouseover = () => {
closeButton.style.backgroundColor = '#4a4a4a';
};
closeButton.onmouseout = () => {
closeButton.style.backgroundColor = 'transparent';
};
closeButton.onclick = (e) => { closeButton.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
this.closeBlendModeMenu(); this.closeBlendModeMenu();
@@ -699,27 +1169,55 @@ export class CanvasLayers {
titleBar.appendChild(titleText); titleBar.appendChild(titleText);
titleBar.appendChild(closeButton); titleBar.appendChild(closeButton);
const content = document.createElement('div'); const content = document.createElement('div');
content.style.cssText = `padding: 5px;`; content.className = 'blend-menu-content';
menu.appendChild(titleBar); menu.appendChild(titleBar);
menu.appendChild(content); menu.appendChild(content);
const blendAreaContainer = document.createElement('div'); 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'); const blendAreaLabel = document.createElement('label');
blendAreaLabel.textContent = 'Blend Area'; blendAreaLabel.textContent = 'Blend Area';
blendAreaLabel.style.color = 'white'; blendAreaLabel.className = 'blend-area-label';
const blendAreaSlider = document.createElement('input'); const blendAreaSlider = document.createElement('input');
blendAreaSlider.type = 'range'; blendAreaSlider.type = 'range';
blendAreaSlider.min = '0'; blendAreaSlider.min = '0';
blendAreaSlider.max = '100'; blendAreaSlider.max = '100';
blendAreaSlider.className = 'blend-area-slider';
blendAreaSlider.value = selectedLayer?.blendArea?.toString() ?? '0'; blendAreaSlider.value = selectedLayer?.blendArea?.toString() ?? '0';
blendAreaSlider.oninput = () => { blendAreaSlider.oninput = () => {
if (selectedLayer) { if (selectedLayer) {
const newValue = parseInt(blendAreaSlider.value, 10); const newValue = parseInt(blendAreaSlider.value, 10);
selectedLayer.blendArea = newValue; 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(); this.canvas.render();
} }
}; };
blendAreaSlider.addEventListener('change', () => { 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(); this.canvas.saveState();
}); });
blendAreaContainer.appendChild(blendAreaLabel); blendAreaContainer.appendChild(blendAreaLabel);
@@ -754,20 +1252,19 @@ export class CanvasLayers {
this.blendModes.forEach((mode) => { this.blendModes.forEach((mode) => {
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'blend-mode-container'; container.className = 'blend-mode-container';
container.style.cssText = `margin-bottom: 5px;`;
const option = document.createElement('div'); 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})`; option.textContent = `${mode.label} (${mode.name})`;
const slider = document.createElement('input'); const slider = document.createElement('input');
slider.type = 'range'; slider.type = 'range';
slider.min = '0'; slider.min = '0';
slider.max = '100'; slider.max = '100';
slider.className = 'blend-opacity-slider';
const selectedLayer = this.canvas.canvasSelection.selectedLayers[0]; const selectedLayer = this.canvas.canvasSelection.selectedLayers[0];
slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100'; 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) { if (selectedLayer && selectedLayer.blendMode === mode.name) {
slider.style.display = 'block'; container.classList.add('active');
option.style.backgroundColor = '#3a3a3a'; option.classList.add('active');
} }
option.onclick = () => { option.onclick = () => {
// Re-check selected layer at the time of click // Re-check selected layer at the time of click
@@ -775,19 +1272,17 @@ export class CanvasLayers {
if (!currentSelectedLayer) { if (!currentSelectedLayer) {
return; 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 => { content.querySelectorAll('.blend-mode-container').forEach(c => {
const opacitySlider = c.querySelector('input[type="range"]'); c.classList.remove('active');
if (opacitySlider) { const optionDiv = c.querySelector('.blend-mode-option');
opacitySlider.style.display = 'none';
}
const optionDiv = c.querySelector('div');
if (optionDiv) { if (optionDiv) {
optionDiv.style.backgroundColor = ''; optionDiv.classList.remove('active');
} }
}); });
slider.style.display = 'block'; // Add active class to current container and option
option.style.backgroundColor = '#3a3a3a'; container.classList.add('active');
option.classList.add('active');
currentSelectedLayer.blendMode = mode.name; currentSelectedLayer.blendMode = mode.name;
this.canvas.render(); this.canvas.render();
}; };

View File

@@ -1,6 +1,7 @@
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js"; import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { createCanvas } from "./utils/CommonUtils.js"; import { createCanvas } from "./utils/CommonUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
const log = createModuleLogger('CanvasLayersPanel'); const log = createModuleLogger('CanvasLayersPanel');
export class CanvasLayersPanel { export class CanvasLayersPanel {
constructor(canvas) { constructor(canvas) {
@@ -18,6 +19,8 @@ export class CanvasLayersPanel {
this.handleDrop = this.handleDrop.bind(this); this.handleDrop = this.handleDrop.bind(this);
// Preload icons // Preload icons
this.initializeIcons(); this.initializeIcons();
// Load CSS for layers panel
addStylesheet(getUrl('./css/layers_panel.css'));
log.info('CanvasLayersPanel initialized'); log.info('CanvasLayersPanel initialized');
} }
async initializeIcons() { async initializeIcons() {
@@ -31,22 +34,15 @@ export class CanvasLayersPanel {
} }
createIconElement(toolName, size = 16) { createIconElement(toolName, size = 16) {
const iconContainer = document.createElement('div'); const iconContainer = document.createElement('div');
iconContainer.style.cssText = ` iconContainer.className = 'icon-container';
width: ${size}px; iconContainer.style.width = `${size}px`;
height: ${size}px; iconContainer.style.height = `${size}px`;
display: flex;
align-items: center;
justify-content: center;
`;
const icon = iconLoader.getIcon(toolName); const icon = iconLoader.getIcon(toolName);
if (icon) { if (icon) {
if (icon instanceof HTMLImageElement) { if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode(); const img = icon.cloneNode();
img.style.cssText = ` img.style.width = `${size}px`;
width: ${size}px; img.style.height = `${size}px`;
height: ${size}px;
filter: brightness(0) invert(1);
`;
iconContainer.appendChild(img); iconContainer.appendChild(img);
} }
else if (icon instanceof HTMLCanvasElement) { else if (icon instanceof HTMLCanvasElement) {
@@ -59,9 +55,9 @@ export class CanvasLayersPanel {
} }
else { else {
// Fallback text // Fallback text
iconContainer.classList.add('fallback-text');
iconContainer.textContent = toolName.charAt(0).toUpperCase(); iconContainer.textContent = toolName.charAt(0).toUpperCase();
iconContainer.style.fontSize = `${size * 0.6}px`; iconContainer.style.fontSize = `${size * 0.6}px`;
iconContainer.style.color = '#ffffff';
} }
return iconContainer; return iconContainer;
} }
@@ -72,24 +68,15 @@ export class CanvasLayersPanel {
else { else {
// Create a "hidden" version of the visibility icon // Create a "hidden" version of the visibility icon
const iconContainer = document.createElement('div'); const iconContainer = document.createElement('div');
iconContainer.style.cssText = ` iconContainer.className = 'icon-container visibility-hidden';
width: 16px; iconContainer.style.width = '16px';
height: 16px; iconContainer.style.height = '16px';
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
`;
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY); const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
if (icon) { if (icon) {
if (icon instanceof HTMLImageElement) { if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode(); const img = icon.cloneNode();
img.style.cssText = ` img.style.width = '16px';
width: 16px; img.style.height = '16px';
height: 16px;
filter: brightness(0) invert(1);
opacity: 0.3;
`;
iconContainer.appendChild(img); iconContainer.appendChild(img);
} }
else if (icon instanceof HTMLCanvasElement) { else if (icon instanceof HTMLCanvasElement) {
@@ -103,9 +90,9 @@ export class CanvasLayersPanel {
} }
else { else {
// Fallback // Fallback
iconContainer.classList.add('fallback-text');
iconContainer.textContent = 'H'; iconContainer.textContent = 'H';
iconContainer.style.fontSize = '10px'; iconContainer.style.fontSize = '10px';
iconContainer.style.color = '#888888';
} }
return iconContainer; return iconContainer;
} }
@@ -126,7 +113,6 @@ export class CanvasLayersPanel {
</div> </div>
`; `;
this.layersContainer = this.container.querySelector('#layers-container'); this.layersContainer = this.container.querySelector('#layers-container');
this.injectStyles();
// Setup event listeners dla przycisków // Setup event listeners dla przycisków
this.setupControlButtons(); this.setupControlButtons();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu // Dodaj listener dla klawiatury, aby usuwanie działało z panelu
@@ -140,212 +126,6 @@ export class CanvasLayersPanel {
log.debug('Panel structure created'); log.debug('Panel structure created');
return this.container; 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() { setupControlButtons() {
if (!this.container) if (!this.container)
return; return;
@@ -359,6 +139,8 @@ export class CanvasLayersPanel {
log.info('Delete layer button clicked'); log.info('Delete layer button clicked');
this.deleteSelectedLayers(); this.deleteSelectedLayers();
}); });
// Initial button state update
this.updateButtonStates();
} }
renderLayers() { renderLayers() {
if (!this.layersContainer) { if (!this.layersContainer) {
@@ -448,6 +230,7 @@ export class CanvasLayersPanel {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer); const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
this.canvas.updateSelection(newSelection); this.canvas.updateSelection(newSelection);
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates();
} }
}); });
layerRow.addEventListener('dblclick', (e) => { layerRow.addEventListener('dblclick', (e) => {
@@ -480,6 +263,7 @@ export class CanvasLayersPanel {
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // 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}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
startEditingLayerName(nameElement, layer) { 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). * Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd. * Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
*/ */
onSelectionChanged() { onSelectionChanged() {
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates();
} }
destroy() { destroy() {
if (this.container && this.container.parentNode) { if (this.container && this.container.parentNode) {

View File

@@ -431,39 +431,76 @@ export class CanvasRenderer {
drawSelectionFrame(ctx, layer) { drawSelectionFrame(ctx, layer) {
const lineWidth = 2 / this.canvas.viewport.zoom; const lineWidth = 2 / this.canvas.viewport.zoom;
const handleRadius = 5 / this.canvas.viewport.zoom; const handleRadius = 5 / this.canvas.viewport.zoom;
ctx.strokeStyle = '#00ff00'; if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
ctx.lineWidth = lineWidth; // --- CROP MODE ---
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia) ctx.lineWidth = lineWidth;
const halfW = layer.width / 2; // 1. Draw dashed blue line for the full transform frame (the "original size" container)
const halfH = layer.height / 2; ctx.strokeStyle = '#007bff';
// Górna krawędź ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer); ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
// Prawa krawędź ctx.setLineDash([]);
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer); // 2. Draw solid blue line for the crop bounds
// Dolna krawędź const layerScaleX = layer.width / layer.originalWidth;
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer); const layerScaleY = layer.height / layer.originalHeight;
// Lewa krawędź const s = layer.cropBounds;
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer); const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
// Rysuj linię do uchwytu rotacji (zawsze ciągła) const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
ctx.setLineDash([]); const cropRectW = s.width * layerScaleX;
ctx.beginPath(); const cropRectH = s.height * layerScaleY;
ctx.moveTo(0, -layer.height / 2); ctx.strokeStyle = '#007bff'; // Solid blue
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom); this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
ctx.stroke(); this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
// Rysuj uchwyty 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); const handles = this.canvas.canvasLayers.getHandles(layer);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000'; ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom; 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) { for (const key in handles) {
// Skip rotation handle in crop mode
if (layer.cropMode && key === 'rot')
continue;
const point = handles[key]; const point = handles[key];
ctx.beginPath(); // The handle position is already in world space.
const localX = point.x - (layer.x + layer.width / 2); // We need to convert it to the layer's local, un-rotated space.
const localY = point.y - (layer.y + layer.height / 2); 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 rad = -layer.rotation * Math.PI / 180;
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad); const cos = Math.cos(rad);
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad); const sin = Math.sin(rad);
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2); 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.fill();
ctx.stroke(); ctx.stroke();
} }

View File

@@ -17,6 +17,32 @@ async function createCanvasWidget(node, widget, app) {
onStateChange: () => updateOutput(node, canvas) onStateChange: () => updateOutput(node, canvas)
}); });
const imageCache = new ImageCache(); 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", { const helpTooltip = $el("div.painter-tooltip", {
id: `painter-help-tooltip-${node.id}`, id: `painter-help-tooltip-${node.id}`,
}); });
@@ -72,7 +98,6 @@ async function createCanvasWidget(node, widget, app) {
}), }),
$el("button.painter-button.icon-button", { $el("button.painter-button.icon-button", {
textContent: "?", textContent: "?",
title: "Show shortcuts",
onmouseenter: (e) => { onmouseenter: (e) => {
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts; const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
showTooltip(e.target, content); showTooltip(e.target, content);
@@ -151,34 +176,36 @@ async function createCanvasWidget(node, widget, app) {
$el("span.switch-icon") $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 // Tooltip logic
switchEl.addEventListener("mouseenter", (e) => { switchEl.addEventListener("mouseenter", (e) => {
const checked = switchEl.querySelector('input[type="checkbox"]').checked; const tooltipContent = getCurrentTooltipContent();
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
showTooltip(switchEl, tooltipContent); showTooltip(switchEl, tooltipContent);
}); });
switchEl.addEventListener("mouseleave", hideTooltip); switchEl.addEventListener("mouseleave", hideTooltip);
// Dynamic icon and text update on toggle // Dynamic icon update on toggle
const input = switchEl.querySelector('input[type="checkbox"]'); const input = switchEl.querySelector('input[type="checkbox"]');
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon'); const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
const updateSwitchView = (isClipspace) => { input.addEventListener('change', () => {
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD; updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
const icon = iconLoader.getIcon(iconTool); // Update tooltip content immediately after state change
if (icon instanceof HTMLImageElement) { updateTooltipIfVisible();
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));
// Initial state // Initial state
iconLoader.preloadToolIcons().then(() => { iconLoader.preloadToolIcons().then(() => {
updateSwitchView(isClipspace); updateSwitchIcon(knobIcon, isClipspace, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
}); });
return switchEl; return switchEl;
})() })()
@@ -293,6 +320,50 @@ async function createCanvasWidget(node, widget, app) {
]), ]),
$el("div.painter-separator"), $el("div.painter-separator"),
$el("div.painter-button-group", {}, [ $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", { $el("button.painter-button.requires-selection", {
textContent: "Rotate +90°", textContent: "Rotate +90°",
title: "Rotate selected layer(s) by +90 degrees", title: "Rotate selected layer(s) by +90 degrees",
@@ -359,6 +430,8 @@ async function createCanvasWidget(node, widget, app) {
delete newLayer.imageId; delete newLayer.imageId;
canvas.layers[selectedLayerIndex] = newLayer; canvas.layers[selectedLayerIndex] = newLayer;
canvas.canvasSelection.updateSelection([newLayer]); canvas.canvasSelection.updateSelection([newLayer]);
// Invalidate processed image cache when layer image changes (matting)
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
canvas.render(); canvas.render();
canvas.saveState(); canvas.saveState();
showSuccessNotification("Background removed successfully!"); showSuccessNotification("Background removed successfully!");
@@ -395,7 +468,8 @@ async function createCanvasWidget(node, widget, app) {
$el("div.painter-button-group", { id: "mask-controls" }, [ $el("div.painter-button-group", { id: "mask-controls" }, [
$el("label.clipboard-switch.mask-switch", { $el("label.clipboard-switch.mask-switch", {
id: `toggle-mask-switch-${node.id}`, 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", { $el("input", {
type: "checkbox", type: "checkbox",
@@ -629,19 +703,38 @@ async function createCanvasWidget(node, widget, app) {
const updateButtonStates = () => { const updateButtonStates = () => {
const selectionCount = canvas.canvasSelection.selectedLayers.length; const selectionCount = canvas.canvasSelection.selectedLayers.length;
const hasSelection = selectionCount > 0; const hasSelection = selectionCount > 0;
controlPanel.querySelectorAll('.requires-selection').forEach((btn) => { // --- Handle Standard Buttons ---
const button = btn; controlPanel.querySelectorAll('.requires-selection').forEach((el) => {
if (button.textContent === 'Fuse') { if (el.tagName === 'BUTTON') {
button.disabled = selectionCount < 2; if (el.textContent === 'Fuse') {
} el.disabled = selectionCount < 2;
else { }
button.disabled = !hasSelection; else {
el.disabled = !hasSelection;
}
} }
}); });
const mattingBtn = controlPanel.querySelector('.matting-button'); const mattingBtn = controlPanel.querySelector('.matting-button');
if (mattingBtn && !mattingBtn.classList.contains('loading')) { if (mattingBtn && !mattingBtn.classList.contains('loading')) {
mattingBtn.disabled = selectionCount !== 1; 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; canvas.canvasSelection.onSelectionChange = updateButtonStates;
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`); const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
@@ -921,7 +1014,7 @@ async function createCanvasWidget(node, widget, app) {
} }
const canvasNodeInstances = new Map(); const canvasNodeInstances = new Map();
app.registerExtension({ app.registerExtension({
name: "Comfy.CanvasNode", name: "Comfy.LayerForgeNode",
init() { init() {
addStylesheet(getUrl('./css/canvas_view.css')); addStylesheet(getUrl('./css/canvas_view.css'));
const originalQueuePrompt = app.queuePrompt; const originalQueuePrompt = app.queuePrompt;
@@ -955,7 +1048,7 @@ app.registerExtension({
}; };
}, },
async beforeRegisterNodeDef(nodeType, nodeData, app) { async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "CanvasNode") { if (nodeType.comfyClass === "LayerForgeNode") {
const onNodeCreated = nodeType.prototype.onNodeCreated; const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
log.debug("CanvasNode onNodeCreated: Base widget setup."); log.debug("CanvasNode onNodeCreated: Base widget setup.");

170
js/css/blend_mode_menu.css Normal file
View 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;
}

View File

@@ -7,7 +7,7 @@
font-size: 12px; font-size: 12px;
font-weight: 550; font-weight: 550;
cursor: pointer; 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; min-width: 80px;
text-align: center; text-align: center;
margin: 2px; margin: 2px;
@@ -51,6 +51,32 @@
border-color: #3a76d6; 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 { .painter-button.success {
border-color: #4ae27a; border-color: #4ae27a;
background-color: #444; background-color: #444;
@@ -187,7 +213,7 @@
border-radius: 5px; border-radius: 5px;
border: 1px solid #555; border: 1px solid #555;
cursor: pointer; 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; user-select: none;
padding: 0; padding: 0;
font-family: inherit; font-family: inherit;
@@ -306,6 +332,25 @@
opacity: 0; 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 { .painter-separator {
width: 1px; width: 1px;

230
js/css/layers_panel.css Normal file
View 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;
}

View File

@@ -19,13 +19,19 @@ export const LAYERFORGE_TOOLS = {
SETTINGS: 'settings', SETTINGS: 'settings',
SYSTEM_CLIPBOARD: 'system_clipboard', SYSTEM_CLIPBOARD: 'system_clipboard',
CLIPSPACE: 'clipspace', CLIPSPACE: 'clipspace',
CROP: 'crop',
TRANSFORM: 'transform',
}; };
// SVG Icons for LayerForge tools // 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 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 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 = { const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`, [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.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.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.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>')}`, [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.BRUSH]: '#4285F4',
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05', [LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01', [LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292' [LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
}; };
export class IconLoader { export class IconLoader {
constructor() { constructor() {

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "layerforge" name = "layerforge"
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing." description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
version = "1.5.1" version = "1.5.3"
license = { text = "MIT License" } license = { text = "MIT License" }
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"] dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]

View File

@@ -166,10 +166,14 @@ export class BatchPreviewManager {
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible; this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
if (this.maskWasVisible) { if (this.maskWasVisible) {
this.canvas.maskTool.toggleOverlayVisibility(); this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`); const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
if (toggleBtn) { if (toggleSwitch) {
toggleBtn.classList.remove('primary'); const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement; if (checkbox) {
checkbox.checked = false;
}
toggleSwitch.classList.remove('primary');
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
if (iconContainer) { if (iconContainer) {
iconContainer.style.opacity = '0.5'; iconContainer.style.opacity = '0.5';
} }
@@ -218,10 +222,14 @@ export class BatchPreviewManager {
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) { if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
this.canvas.maskTool.toggleOverlayVisibility(); this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`); const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
if (toggleBtn) { if (toggleSwitch) {
toggleBtn.classList.add('primary'); const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement; if (checkbox) {
checkbox.checked = true;
}
toggleSwitch.classList.add('primary');
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
if (iconContainer) { if (iconContainer) {
iconContainer.style.opacity = '1'; iconContainer.style.opacity = '1';
} }

View File

@@ -269,7 +269,12 @@ export class CanvasIO {
log.error(`Failed to send data for node ${nodeId}:`, 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.`
);
} }
} }

View File

@@ -316,6 +316,16 @@ export class CanvasInteractions {
this.logDragCompletion(coords); 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) // Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode); const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag; const duplicatedInDrag = this.interaction.hasClonedInDrag;
@@ -445,6 +455,9 @@ export class CanvasInteractions {
layer.height *= scaleFactor; layer.height *= scaleFactor;
layer.x += (oldWidth - layer.width) / 2; layer.x += (oldWidth - layer.width) / 2;
layer.y += (oldHeight - layer.height) / 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, width: layer.width, height: layer.height,
rotation: layer.rotation, rotation: layer.rotation,
centerX: layer.x + layer.width / 2, 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}; this.interaction.dragStart = {...worldCoords};
@@ -797,66 +813,159 @@ export class CanvasInteractions {
if (this.interaction.isCtrlPressed) { if (this.interaction.isCtrlPressed) {
const snapThreshold = 10 / this.canvas.viewport.zoom; const snapThreshold = 10 / this.canvas.viewport.zoom;
const snappedMouseX = snapToGrid(mouseX); mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX; mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
const snappedMouseY = snapToGrid(mouseY);
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY;
} }
const o = this.interaction.transformOrigin; const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return; if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
const handle = this.interaction.resizeHandle; const handle = this.interaction.resizeHandle;
const anchor = this.interaction.resizeAnchor; const anchor = this.interaction.resizeAnchor;
const rad = o.rotation * Math.PI / 180; const rad = o.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
// Vector from anchor to mouse
const vecX = mouseX - anchor.x; const vecX = mouseX - anchor.x;
const vecY = mouseY - anchor.y; const vecY = mouseY - anchor.y;
let newWidth = vecX * cos + vecY * sin; // Rotate vector to align with layer's local coordinates
let newHeight = vecY * cos - vecX * sin; let localVecX = vecX * cos + vecY * sin;
let localVecY = vecY * cos - vecX * sin;
if (isShiftPressed) { // Determine sign based on handle
const originalAspectRatio = o.width / o.height; 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) { // If not a corner handle, keep original dimension
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; if (signX === 0) localVecX = o.width;
} else { if (signY === 0) localVecY = o.height;
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
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(); this.canvas.render();
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js"; import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { createCanvas } from "./utils/CommonUtils.js"; import { createCanvas } from "./utils/CommonUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
import type { Canvas } from './Canvas'; import type { Canvas } from './Canvas';
import type { Layer } from './types'; import type { Layer } from './types';
@@ -33,6 +34,9 @@ export class CanvasLayersPanel {
// Preload icons // Preload icons
this.initializeIcons(); this.initializeIcons();
// Load CSS for layers panel
addStylesheet(getUrl('./css/layers_panel.css'));
log.info('CanvasLayersPanel initialized'); log.info('CanvasLayersPanel initialized');
} }
@@ -47,23 +51,16 @@ export class CanvasLayersPanel {
private createIconElement(toolName: string, size: number = 16): HTMLElement { private createIconElement(toolName: string, size: number = 16): HTMLElement {
const iconContainer = document.createElement('div'); const iconContainer = document.createElement('div');
iconContainer.style.cssText = ` iconContainer.className = 'icon-container';
width: ${size}px; iconContainer.style.width = `${size}px`;
height: ${size}px; iconContainer.style.height = `${size}px`;
display: flex;
align-items: center;
justify-content: center;
`;
const icon = iconLoader.getIcon(toolName); const icon = iconLoader.getIcon(toolName);
if (icon) { if (icon) {
if (icon instanceof HTMLImageElement) { if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement; const img = icon.cloneNode() as HTMLImageElement;
img.style.cssText = ` img.style.width = `${size}px`;
width: ${size}px; img.style.height = `${size}px`;
height: ${size}px;
filter: brightness(0) invert(1);
`;
iconContainer.appendChild(img); iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) { } else if (icon instanceof HTMLCanvasElement) {
const { canvas, ctx } = createCanvas(size, size); const { canvas, ctx } = createCanvas(size, size);
@@ -74,9 +71,9 @@ export class CanvasLayersPanel {
} }
} else { } else {
// Fallback text // Fallback text
iconContainer.classList.add('fallback-text');
iconContainer.textContent = toolName.charAt(0).toUpperCase(); iconContainer.textContent = toolName.charAt(0).toUpperCase();
iconContainer.style.fontSize = `${size * 0.6}px`; iconContainer.style.fontSize = `${size * 0.6}px`;
iconContainer.style.color = '#ffffff';
} }
return iconContainer; return iconContainer;
@@ -88,25 +85,16 @@ export class CanvasLayersPanel {
} else { } else {
// Create a "hidden" version of the visibility icon // Create a "hidden" version of the visibility icon
const iconContainer = document.createElement('div'); const iconContainer = document.createElement('div');
iconContainer.style.cssText = ` iconContainer.className = 'icon-container visibility-hidden';
width: 16px; iconContainer.style.width = '16px';
height: 16px; iconContainer.style.height = '16px';
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
`;
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY); const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
if (icon) { if (icon) {
if (icon instanceof HTMLImageElement) { if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement; const img = icon.cloneNode() as HTMLImageElement;
img.style.cssText = ` img.style.width = '16px';
width: 16px; img.style.height = '16px';
height: 16px;
filter: brightness(0) invert(1);
opacity: 0.3;
`;
iconContainer.appendChild(img); iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) { } else if (icon instanceof HTMLCanvasElement) {
const { canvas, ctx } = createCanvas(16, 16); const { canvas, ctx } = createCanvas(16, 16);
@@ -118,9 +106,9 @@ export class CanvasLayersPanel {
} }
} else { } else {
// Fallback // Fallback
iconContainer.classList.add('fallback-text');
iconContainer.textContent = 'H'; iconContainer.textContent = 'H';
iconContainer.style.fontSize = '10px'; iconContainer.style.fontSize = '10px';
iconContainer.style.color = '#888888';
} }
return iconContainer; return iconContainer;
@@ -144,8 +132,6 @@ export class CanvasLayersPanel {
`; `;
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container'); this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
this.injectStyles();
// Setup event listeners dla przycisków // Setup event listeners dla przycisków
this.setupControlButtons(); this.setupControlButtons();
@@ -163,218 +149,10 @@ export class CanvasLayersPanel {
return this.container; 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 { setupControlButtons(): void {
if (!this.container) return; 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 // Add delete icon to button
if (deleteBtn) { if (deleteBtn) {
@@ -386,6 +164,9 @@ export class CanvasLayersPanel {
log.info('Delete layer button clicked'); log.info('Delete layer button clicked');
this.deleteSelectedLayers(); this.deleteSelectedLayers();
}); });
// Initial button state update
this.updateButtonStates();
} }
renderLayers(): void { renderLayers(): void {
@@ -495,6 +276,7 @@ export class CanvasLayersPanel {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer); const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.updateSelection(newSelection); this.canvas.updateSelection(newSelection);
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates();
} }
}); });
@@ -532,7 +314,8 @@ export class CanvasLayersPanel {
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // 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}`); 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). * Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd. * Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
*/ */
onSelectionChanged(): void { onSelectionChanged(): void {
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates();
} }
destroy(): void { destroy(): void {

View File

@@ -532,46 +532,89 @@ export class CanvasRenderer {
drawSelectionFrame(ctx: any, layer: any) { drawSelectionFrame(ctx: any, layer: any) {
const lineWidth = 2 / this.canvas.viewport.zoom; const lineWidth = 2 / this.canvas.viewport.zoom;
const handleRadius = 5 / 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) // --- DRAW HANDLES (Unified Logic) ---
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
const handles = this.canvas.canvasLayers.getHandles(layer); const handles = this.canvas.canvasLayers.getHandles(layer);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000'; ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom; 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) { for (const key in handles) {
// Skip rotation handle in crop mode
if (layer.cropMode && key === 'rot') continue;
const point = handles[key]; const point = handles[key];
ctx.beginPath(); // The handle position is already in world space.
const localX = point.x - (layer.x + layer.width / 2); // We need to convert it to the layer's local, un-rotated space.
const localY = point.y - (layer.y + layer.height / 2); 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 rad = -layer.rotation * Math.PI / 180;
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad); const cos = Math.cos(rad);
const rotatedY = localX * Math.sin(rad) + localY * 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.fill();
ctx.stroke(); ctx.stroke();
} }

View File

@@ -33,6 +33,40 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
}); });
const imageCache = new ImageCache(); 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", { const helpTooltip = $el("div.painter-tooltip", {
id: `painter-help-tooltip-${node.id}`, id: `painter-help-tooltip-${node.id}`,
}) as HTMLDivElement; }) as HTMLDivElement;
@@ -97,7 +131,6 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
}), }),
$el("button.painter-button.icon-button", { $el("button.painter-button.icon-button", {
textContent: "?", textContent: "?",
title: "Show shortcuts",
onmouseenter: (e: MouseEvent) => { onmouseenter: (e: MouseEvent) => {
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts; const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
showTooltip(e.target as HTMLElement, content); 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 // Tooltip logic
switchEl.addEventListener("mouseenter", (e: MouseEvent) => { switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked; const tooltipContent = getCurrentTooltipContent();
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
showTooltip(switchEl, tooltipContent); showTooltip(switchEl, tooltipContent);
}); });
switchEl.addEventListener("mouseleave", hideTooltip); 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 input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement; const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement;
const updateSwitchView = (isClipspace: boolean) => { input.addEventListener('change', () => {
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD; updateSwitchIcon(
const icon = iconLoader.getIcon(iconTool); knobIcon,
if (icon instanceof HTMLImageElement) { input.checked,
knobIcon.innerHTML = ''; LAYERFORGE_TOOLS.CLIPSPACE,
const clonedIcon = icon.cloneNode() as HTMLImageElement; LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
clonedIcon.style.width = '20px'; "🗂️",
clonedIcon.style.height = '20px'; "📋"
knobIcon.appendChild(clonedIcon); );
} else {
knobIcon.textContent = isClipspace ? "🗂️" : "📋"; // Update tooltip content immediately after state change
} updateTooltipIfVisible();
}; });
input.addEventListener('change', () => updateSwitchView(input.checked));
// Initial state // Initial state
iconLoader.preloadToolIcons().then(() => { iconLoader.preloadToolIcons().then(() => {
updateSwitchView(isClipspace); updateSwitchIcon(
knobIcon,
isClipspace,
LAYERFORGE_TOOLS.CLIPSPACE,
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
"🗂️",
"📋"
);
}); });
return switchEl; return switchEl;
@@ -326,6 +378,68 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
$el("div.painter-separator"), $el("div.painter-separator"),
$el("div.painter-button-group", {}, [ $el("div.painter-button-group", {}, [
(() => {
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", { $el("button.painter-button.requires-selection", {
textContent: "Rotate +90°", textContent: "Rotate +90°",
title: "Rotate selected layer(s) by +90 degrees", 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.layers[selectedLayerIndex] = newLayer;
canvas.canvasSelection.updateSelection([newLayer]); canvas.canvasSelection.updateSelection([newLayer]);
// Invalidate processed image cache when layer image changes (matting)
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
canvas.render(); canvas.render();
canvas.saveState(); canvas.saveState();
showSuccessNotification("Background removed successfully!"); 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("div.painter-button-group", {id: "mask-controls"}, [
$el("label.clipboard-switch.mask-switch", { $el("label.clipboard-switch.mask-switch", {
id: `toggle-mask-switch-${node.id}`, 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", { $el("input", {
type: "checkbox", type: "checkbox",
@@ -672,18 +791,50 @@ $el("label.clipboard-switch.mask-switch", {
const updateButtonStates = () => { const updateButtonStates = () => {
const selectionCount = canvas.canvasSelection.selectedLayers.length; const selectionCount = canvas.canvasSelection.selectedLayers.length;
const hasSelection = selectionCount > 0; const hasSelection = selectionCount > 0;
controlPanel.querySelectorAll('.requires-selection').forEach((btn: any) => {
const button = btn as HTMLButtonElement; // --- Handle Standard Buttons ---
if (button.textContent === 'Fuse') { controlPanel.querySelectorAll('.requires-selection').forEach((el: any) => {
button.disabled = selectionCount < 2; if (el.tagName === 'BUTTON') {
} else { if (el.textContent === 'Fuse') {
button.disabled = !hasSelection; el.disabled = selectionCount < 2;
} else {
el.disabled = !hasSelection;
}
} }
}); });
const mattingBtn = controlPanel.querySelector('.matting-button') as HTMLButtonElement; const mattingBtn = controlPanel.querySelector('.matting-button') as HTMLButtonElement;
if (mattingBtn && !mattingBtn.classList.contains('loading')) { if (mattingBtn && !mattingBtn.classList.contains('loading')) {
mattingBtn.disabled = selectionCount !== 1; 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; canvas.canvasSelection.onSelectionChange = updateButtonStates;
@@ -1014,7 +1165,7 @@ $el("label.clipboard-switch.mask-switch", {
const canvasNodeInstances = new Map<number, CanvasWidget>(); const canvasNodeInstances = new Map<number, CanvasWidget>();
app.registerExtension({ app.registerExtension({
name: "Comfy.CanvasNode", name: "Comfy.LayerForgeNode",
init() { init() {
addStylesheet(getUrl('./css/canvas_view.css')); addStylesheet(getUrl('./css/canvas_view.css'));
@@ -1053,7 +1204,7 @@ app.registerExtension({
}, },
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) { async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
if (nodeType.comfyClass === "CanvasNode") { if (nodeType.comfyClass === "LayerForgeNode") {
const onNodeCreated = nodeType.prototype.onNodeCreated; const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (this: ComfyNode) { nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
log.debug("CanvasNode onNodeCreated: Base widget setup."); log.debug("CanvasNode onNodeCreated: Base widget setup.");

170
src/css/blend_mode_menu.css Normal file
View 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;
}

View File

@@ -7,7 +7,7 @@
font-size: 12px; font-size: 12px;
font-weight: 550; font-weight: 550;
cursor: pointer; 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; min-width: 80px;
text-align: center; text-align: center;
margin: 2px; margin: 2px;
@@ -51,6 +51,32 @@
border-color: #3a76d6; 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 { .painter-button.success {
border-color: #4ae27a; border-color: #4ae27a;
background-color: #444; background-color: #444;
@@ -187,7 +213,7 @@
border-radius: 5px; border-radius: 5px;
border: 1px solid #555; border: 1px solid #555;
cursor: pointer; 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; user-select: none;
padding: 0; padding: 0;
font-family: inherit; font-family: inherit;
@@ -306,6 +332,25 @@
opacity: 0; 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 { .painter-separator {
width: 1px; width: 1px;

230
src/css/layers_panel.css Normal file
View 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;
}

View File

@@ -21,6 +21,13 @@ export interface Layer {
flipH?: boolean; flipH?: boolean;
flipV?: boolean; flipV?: boolean;
blendArea?: number; 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 { export interface ComfyNode {

View File

@@ -13,7 +13,7 @@ export const LAYERFORGE_TOOLS = {
DELETE: 'delete', DELETE: 'delete',
DUPLICATE: 'duplicate', DUPLICATE: 'duplicate',
BLEND_MODE: 'blend_mode', BLEND_MODE: 'blend_mode',
OPACITY: 'opacity', OPACITY: 'opacity',
MASK: 'mask', MASK: 'mask',
BRUSH: 'brush', BRUSH: 'brush',
ERASER: 'eraser', ERASER: 'eraser',
@@ -21,16 +21,22 @@ export const LAYERFORGE_TOOLS = {
SETTINGS: 'settings', SETTINGS: 'settings',
SYSTEM_CLIPBOARD: 'system_clipboard', SYSTEM_CLIPBOARD: 'system_clipboard',
CLIPSPACE: 'clipspace', CLIPSPACE: 'clipspace',
CROP: 'crop',
TRANSFORM: 'transform',
} as const; } as const;
// SVG Icons for LayerForge tools // 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 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 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 = { const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`, [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.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.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.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.BRUSH]: '#4285F4',
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05', [LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01', [LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292' [LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
}; };
export interface IconCache { export interface IconCache {