22 Commits

Author SHA1 Message Date
Dariusz L
bf55d13f67 Update pyproject.toml 2025-08-08 17:14:05 +02:00
Dariusz L
de83a884c2 Switch mask preview from chunked to canvas rendering
Replaced chunked rendering approach with direct canvas drawing for mask preview, then applying to main canvas. Added "Mask Opacity" slider.
2025-08-08 17:13:44 +02:00
Dariusz L
dd2a81b6f2 add advanced brush cursor visualization
Implemented dynamic brush cursor with visual feedback for size (circle radius), strength (opacity), and hardness (solid/dashed border with gradient). Added overlay canvas system for smooth cursor updates without affecting main rendering performance.
2025-08-08 14:20:55 +02:00
Dariusz L
176b9d03ac unify modifier key handling in CanvasInteractions
Implemented centralized modifier state management with ModifierState interface and getModifierState() method. This eliminates inconsistencies between event-based and state-based modifier checking across mouse, wheel, and keyboard interactions.
2025-08-08 13:50:13 +02:00
Dariusz L
e4f44c10e8 resolve TypeScript errors and memory leaks
Fixed all TypeScript compilation errors by defining a dedicated TransformOrigin type and adding proper null checks. Implemented comprehensive event handler cleanup to prevent memory leaks and improved cross-platform support with Meta key handling for macOS users.
2025-08-08 13:15:21 +02:00
Dariusz L
11dd554204 Update pyproject.toml 2025-08-06 23:09:19 +02:00
Dariusz L
9f21ff13ae Add clipspace utils with full backward support
Refactored clipspace handling into ClipspaceUtils with validateAndFixClipspace() and safeClipspacePaste() for consistent, defensive logic. Ensures full backward compatibility with all ComfyUI versions and eliminates duplicated code.
2025-08-06 23:08:02 +02:00
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
41 changed files with 4098 additions and 1402 deletions

View File

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

View File

@@ -90,7 +90,7 @@ class BiRefNet(torch.nn.Module):
return [output]
class CanvasNode:
class LayerForgeNode:
_canvas_data_storage = {}
_storage_lock = threading.Lock()
@@ -912,12 +912,3 @@ def convert_tensor_to_base64(tensor, alpha_mask=None, original_alpha=None):
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
raise
CanvasNode.setup_routes()
NODE_CLASS_MAPPINGS = {
"CanvasNode": CanvasNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CanvasNode": "LayerForge"
}

View File

@@ -1,8 +1,8 @@
{
"id": "d26732fd-91ea-4503-8d0d-383544823cec",
"revision": 0,
"last_node_id": 49,
"last_link_id": 112,
"last_node_id": 52,
"last_link_id": 114,
"nodes": [
{
"id": 7,
@@ -18,7 +18,7 @@
"flags": {
"collapsed": true
},
"order": 6,
"order": 8,
"mode": 0,
"inputs": [
{
@@ -62,7 +62,7 @@
58
],
"flags": {},
"order": 8,
"order": 10,
"mode": 0,
"inputs": [
{
@@ -103,7 +103,7 @@
26
],
"flags": {},
"order": 7,
"order": 9,
"mode": 0,
"inputs": [
{
@@ -260,7 +260,7 @@
46
],
"flags": {},
"order": 12,
"order": 14,
"mode": 0,
"inputs": [
{
@@ -304,7 +304,7 @@
58
],
"flags": {},
"order": 10,
"order": 12,
"mode": 0,
"inputs": [
{
@@ -344,7 +344,7 @@
138
],
"flags": {},
"order": 9,
"order": 11,
"mode": 0,
"inputs": [
{
@@ -365,12 +365,12 @@
{
"name": "pixels",
"type": "IMAGE",
"link": 106
"link": 113
},
{
"name": "mask",
"type": "MASK",
"link": 107
"link": 114
}
],
"outputs": [
@@ -421,7 +421,7 @@
262
],
"flags": {},
"order": 11,
"order": 13,
"mode": 0,
"inputs": [
{
@@ -462,7 +462,7 @@
"widget_ue_connectable": {}
},
"widgets_values": [
858769863184862,
1006953529460557,
"randomize",
20,
1,
@@ -526,7 +526,7 @@
893.8499755859375
],
"flags": {},
"order": 13,
"order": 15,
"mode": 0,
"inputs": [
{
@@ -550,15 +550,15 @@
"id": 23,
"type": "CLIPTextEncode",
"pos": [
-835.4583129882812,
878.8148193359375
-905.195556640625,
924.5140991210938
],
"size": [
311.0955810546875,
108.43277740478516
],
"flags": {},
"order": 5,
"order": 7,
"mode": 0,
"inputs": [
{
@@ -591,48 +591,94 @@
"bgcolor": "#353"
},
{
"id": 48,
"type": "CanvasNode",
"id": 51,
"type": "Note",
"pos": [
-514.2837524414062,
543.1272583007812
-916.8970947265625,
476.72564697265625
],
"size": [
1862.893798828125,
1237.79638671875
350.92510986328125,
250.50831604003906
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"How to Use Polygonal Selection\n- Start Drawing: Hold Shift + S and left-click to place the first point of your polygonal selection.\n\n- Add Points: Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.\n\n- Close Selection: Click back on the first point (or close to it) to complete and close the polygonal selection.\n\n- Run Inpainting: Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 52,
"type": "Note",
"pos": [
-911.10205078125,
769.1378173828125
],
"size": [
350.28143310546875,
99.23915100097656
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Add a description at the bottom to tell the model what to generate."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 50,
"type": "LayerForgeNode",
"pos": [
-553.9073486328125,
478.2644348144531
],
"size": [
1879.927490234375,
1259.4072265625
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
106
113
]
},
{
"name": "mask",
"type": "MASK",
"links": [
107
114
]
}
],
"properties": {
"cnr_id": "layerforge",
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
"Node name for S&R": "CanvasNode",
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
"Node name for S&R": "LayerForgeNode",
"widget_ue_connectable": {}
},
"widgets_values": [
false,
false,
true,
963,
"48",
18,
"50",
""
]
}
@@ -734,22 +780,6 @@
0,
"IMAGE"
],
[
106,
48,
0,
38,
3,
"IMAGE"
],
[
107,
48,
1,
38,
4,
"MASK"
],
[
110,
38,
@@ -773,6 +803,22 @@
8,
0,
"LATENT"
],
[
113,
50,
0,
38,
3,
"IMAGE"
],
[
114,
50,
1,
38,
4,
"MASK"
]
],
"groups": [],
@@ -781,8 +827,8 @@
"ds": {
"scale": 0.6588450000000008,
"offset": [
1318.77716124466,
-32.39290875553955
1117.7398801488407,
-110.40634975151642
]
},
"ue_links": [],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -1,19 +1,137 @@
{
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
"revision": 0,
"last_node_id": 707,
"last_link_id": 1499,
"last_node_id": 710,
"last_link_id": 1505,
"nodes": [
{
"id": 708,
"type": "LayerForgeNode",
"pos": [
-3077.55615234375,
-3358.0537109375
],
"size": [
1150,
1000
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
1500
]
},
{
"name": "mask",
"type": "MASK",
"links": [
1501
]
}
],
"properties": {
"cnr_id": "layerforge",
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
"widget_ue_connectable": {},
"Node name for S&R": "LayerForgeNode"
},
"widgets_values": [
false,
false,
false,
11,
"708",
""
]
},
{
"id": 709,
"type": "Reroute",
"pos": [
-1920.4510498046875,
-3559.688232421875
],
"size": [
75,
26
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 1500
}
],
"outputs": [
{
"name": "",
"type": "IMAGE",
"links": [
1502,
1503
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 710,
"type": "Reroute",
"pos": [
-1917.6273193359375,
-3524.312744140625
],
"size": [
75,
26
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 1501
}
],
"outputs": [
{
"name": "",
"type": "MASK",
"links": [
1504,
1505
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 369,
"type": "PreviewImage",
"pos": [
-1699.1021728515625,
-3355.60498046875
-1914.3177490234375,
-2807.92724609375
],
"size": [
660.91162109375,
400.2092590332031
710,
450
],
"flags": {},
"order": 6,
@@ -38,21 +156,21 @@
"id": 606,
"type": "PreviewImage",
"pos": [
-1911.126708984375,
-2916.072998046875
-1913.4202880859375,
-3428.773193359375
],
"size": [
551.7399291992188,
546.8018798828125
700,
510
],
"flags": {},
"order": 1,
"order": 3,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1495
"link": 1503
}
],
"outputs": [],
@@ -64,92 +182,30 @@
},
"widgets_values": []
},
{
"id": 603,
"type": "PreviewImage",
"pos": [
-1344.1650390625,
-2915.117919921875
],
"size": [
601.4136962890625,
527.1531372070312
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1236
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "PreviewImage",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 680,
"type": "SaveImage",
"pos": [
-1025.9984130859375,
-3357.975341796875
],
"size": [
278.8309020996094,
395.84002685546875
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1465
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "SaveImage",
"widget_ue_connectable": {}
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 442,
"type": "JoinImageWithAlpha",
"pos": [
-1902.5858154296875,
-3187.159423828125
-1190.1787109375,
-3237.75732421875
],
"size": [
176.86483764648438,
46
],
"flags": {},
"order": 2,
"order": 5,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1494
"link": 1502
},
{
"name": "alpha",
"type": "MASK",
"link": 1497
"link": 1505
}
],
"outputs": [
@@ -170,25 +226,87 @@
},
"widgets_values": []
},
{
"id": 603,
"type": "PreviewImage",
"pos": [
-1188.5968017578125,
-3143.6875
],
"size": [
640,
510
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1236
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "PreviewImage",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 680,
"type": "SaveImage",
"pos": [
-536.2315673828125,
-3135.49755859375
],
"size": [
279.97137451171875,
282
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1465
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "SaveImage",
"widget_ue_connectable": {}
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 706,
"type": "MaskToImage",
"pos": [
-1901.433349609375,
-3332.2021484375
-1911.38525390625,
-2875.74658203125
],
"size": [
184.62362670898438,
26
],
"flags": {},
"order": 3,
"order": 4,
"mode": 0,
"inputs": [
{
"name": "mask",
"type": "MASK",
"link": 1498
"link": 1504
}
],
"outputs": [
@@ -203,57 +321,10 @@
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"widget_ue_connectable": {},
"Node name for S&R": "MaskToImage"
}
},
{
"id": 697,
"type": "CanvasNode",
"pos": [
-2968.572998046875,
-3347.89306640625
],
"size": [
1044.9053955078125,
980.680908203125
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
1494,
1495
]
},
{
"name": "mask",
"type": "MASK",
"links": [
1497,
1498
]
}
],
"properties": {
"cnr_id": "layerforge",
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
"Node name for S&R": "CanvasNode",
"Node name for S&R": "MaskToImage",
"widget_ue_connectable": {}
},
"widgets_values": [
true,
false,
"697",
15,
"697",
""
]
"widgets_values": []
}
],
"links": [
@@ -273,38 +344,6 @@
0,
"IMAGE"
],
[
1494,
697,
0,
442,
0,
"IMAGE"
],
[
1495,
697,
0,
606,
0,
"IMAGE"
],
[
1497,
697,
1,
442,
1,
"MASK"
],
[
1498,
697,
1,
706,
0,
"MASK"
],
[
1499,
706,
@@ -312,16 +351,64 @@
369,
0,
"IMAGE"
],
[
1500,
708,
0,
709,
0,
"*"
],
[
1501,
708,
1,
710,
0,
"*"
],
[
1502,
709,
0,
442,
0,
"IMAGE"
],
[
1503,
709,
0,
606,
0,
"IMAGE"
],
[
1504,
710,
0,
706,
0,
"MASK"
],
[
1505,
710,
0,
442,
1,
"MASK"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.9646149645000008,
"scale": 0.7972024500000005,
"offset": [
3002.5649125522764,
3543.443319064718
3208.3419155969927,
3617.011371212156
]
},
"ue_links": [],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 939 KiB

After

Width:  |  Height:  |  Size: 854 KiB

View File

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

View File

@@ -61,6 +61,15 @@ export class Canvas {
});
this.offscreenCanvas = offscreenCanvas;
this.offscreenCtx = offscreenCtx;
// Create overlay canvas for brush cursor and other lightweight overlays
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
alpha: true,
willReadFrequently: false
});
if (!overlayCtx)
throw new Error("Could not create overlay canvas context");
this.overlayCanvas = overlayCanvas;
this.overlayCtx = overlayCtx;
this.canvasContainer = null;
this.dataInitialized = false;
this.pendingDataCheck = null;

View File

@@ -238,7 +238,10 @@ export class CanvasIO {
}
catch (error) {
log.error(`Failed to send data for node ${nodeId}:`, error);
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
throw new Error(`Failed to get confirmation from server for node ${nodeId}. ` +
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`);
}
}
async addInputToCanvas(inputImage, inputMask) {

View File

@@ -3,16 +3,33 @@ import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
const log = createModuleLogger('CanvasInteractions');
export class CanvasInteractions {
constructor(canvas) {
// Bound event handlers to enable proper removeEventListener and avoid leaks
this.onMouseDown = (e) => this.handleMouseDown(e);
this.onMouseMove = (e) => this.handleMouseMove(e);
this.onMouseUp = (e) => this.handleMouseUp(e);
this.onMouseEnter = (e) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); };
this.onMouseLeave = (e) => { this.canvas.isMouseOver = false; this.handleMouseLeave(e); };
this.onWheel = (e) => this.handleWheel(e);
this.onKeyDown = (e) => this.handleKeyDown(e);
this.onKeyUp = (e) => this.handleKeyUp(e);
this.onDragOver = (e) => this.handleDragOver(e);
this.onDragEnter = (e) => this.handleDragEnter(e);
this.onDragLeave = (e) => this.handleDragLeave(e);
this.onDrop = (e) => { this.handleDrop(e); };
this.onContextMenu = (e) => this.handleContextMenu(e);
this.onBlur = () => this.handleBlur();
this.onPaste = (e) => this.handlePasteEvent(e);
this.canvas = canvas;
this.interaction = {
mode: 'none',
panStart: { x: 0, y: 0 },
dragStart: { x: 0, y: 0 },
transformOrigin: {},
transformOrigin: null,
resizeHandle: null,
resizeAnchor: { x: 0, y: 0 },
canvasResizeStart: { x: 0, y: 0 },
isCtrlPressed: false,
isMetaPressed: false,
isAltPressed: false,
isShiftPressed: false,
isSPressed: false,
@@ -32,18 +49,29 @@ export class CanvasInteractions {
view: this.canvas.getMouseViewCoordinates(e)
};
}
getModifierState(e) {
return {
ctrl: this.interaction.isCtrlPressed || e?.ctrlKey || false,
shift: this.interaction.isShiftPressed || e?.shiftKey || false,
alt: this.interaction.isAltPressed || e?.altKey || false,
meta: this.interaction.isMetaPressed || e?.metaKey || false,
};
}
preventEventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
performZoomOperation(worldCoords, zoomFactor) {
const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
this.canvas.viewport.zoom = newZoom;
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
// Update stroke overlay if mask tool is drawing during zoom
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.onViewportChange?.();
}
renderAndSave(shouldSave = false) {
@@ -64,29 +92,39 @@ export class CanvasInteractions {
}
}
setupEventListeners() {
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown);
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp);
this.canvas.canvas.addEventListener('wheel', this.onWheel, { passive: false });
this.canvas.canvas.addEventListener('keydown', this.onKeyDown);
this.canvas.canvas.addEventListener('keyup', this.onKeyUp);
// Add a blur event listener to the window to reset key states
window.addEventListener('blur', this.handleBlur.bind(this));
document.addEventListener('paste', this.handlePasteEvent.bind(this));
this.canvas.canvas.addEventListener('mouseenter', (e) => {
this.canvas.isMouseOver = true;
this.handleMouseEnter(e);
});
this.canvas.canvas.addEventListener('mouseleave', (e) => {
this.canvas.isMouseOver = false;
this.handleMouseLeave(e);
});
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
window.addEventListener('blur', this.onBlur);
document.addEventListener('paste', this.onPaste);
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
this.canvas.canvas.addEventListener('dragover', this.onDragOver);
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter);
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave);
this.canvas.canvas.addEventListener('drop', this.onDrop);
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu);
}
teardownEventListeners() {
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown);
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove);
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp);
this.canvas.canvas.removeEventListener('wheel', this.onWheel);
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste);
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter);
this.canvas.canvas.removeEventListener('mouseleave', this.onMouseLeave);
this.canvas.canvas.removeEventListener('dragover', this.onDragOver);
this.canvas.canvas.removeEventListener('dragenter', this.onDragEnter);
this.canvas.canvas.removeEventListener('dragleave', this.onDragLeave);
this.canvas.canvas.removeEventListener('drop', this.onDrop);
this.canvas.canvas.removeEventListener('contextmenu', this.onContextMenu);
}
/**
* Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów
@@ -124,9 +162,10 @@ export class CanvasInteractions {
handleMouseDown(e) {
this.canvas.canvas.focus();
const coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
this.canvas.render();
// Don't render here - mask tool will handle its own drawing
return;
}
if (this.canvas.shapeTool.isActive) {
@@ -135,11 +174,11 @@ export class CanvasInteractions {
}
// --- Ostateczna, poprawna kolejność sprawdzania ---
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
if (e.shiftKey && e.ctrlKey) {
if (mods.shift && mods.ctrl) {
this.startCanvasMove(coords.world);
return;
}
if (e.shiftKey) {
if (mods.shift) {
// Clear custom shape when starting canvas resize
if (this.canvas.outputAreaShape) {
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
@@ -163,7 +202,7 @@ export class CanvasInteractions {
}
return;
}
if (e.button !== 0) { // Środkowy przycisk
if (e.button === 1) { // Środkowy przycisk
this.startPanning(e);
return;
}
@@ -179,7 +218,7 @@ export class CanvasInteractions {
return;
}
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
this.startPanningOrClearSelection(e);
this.startPanning(e, true); // clearSelection = true
}
handleMouseMove(e) {
const coords = this.getMouseCoordinates(e);
@@ -199,7 +238,7 @@ export class CanvasInteractions {
switch (this.interaction.mode) {
case 'drawingMask':
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
this.canvas.render();
// Don't render during mask drawing - it's handled by mask tool internally
break;
case 'panning':
this.panViewport(e);
@@ -221,6 +260,10 @@ export class CanvasInteractions {
break;
default:
this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) {
this.canvas.canvasRenderer.drawMaskBrushCursor(coords.world);
}
break;
}
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
@@ -232,6 +275,7 @@ export class CanvasInteractions {
const coords = this.getMouseCoordinates(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(coords.view);
// Render only once after drawing is complete
this.canvas.render();
return;
}
@@ -245,6 +289,14 @@ export class CanvasInteractions {
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
this.logDragCompletion(coords);
}
// Handle end of crop bounds transformation before resetting interaction state
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
}
// Handle end of scale transformation (normal transform mode) before resetting interaction state
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
}
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag;
@@ -307,8 +359,17 @@ export class CanvasInteractions {
this.performZoomOperation(coords.world, zoomFactor);
}
else {
// Layer transformation when layers are selected
this.handleLayerWheelTransformation(e);
// Check if mouse is over any selected layer
const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y);
if (isOverSelectedLayer) {
// Layer transformation when layers are selected and mouse is over selected layer
this.handleLayerWheelTransformation(e);
}
else {
// Zoom operation when mouse is not over selected layers
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
this.performZoomOperation(coords.world, zoomFactor);
}
}
this.canvas.render();
if (!this.canvas.maskTool.isActive) {
@@ -316,14 +377,15 @@ export class CanvasInteractions {
}
}
handleLayerWheelTransformation(e) {
const mods = this.getModifierState(e);
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
const direction = e.deltaY < 0 ? 1 : -1;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
if (e.shiftKey) {
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
if (mods.shift) {
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
}
else {
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
this.handleLayerScaling(layer, mods.ctrl, e.deltaY);
}
});
}
@@ -363,10 +425,12 @@ export class CanvasInteractions {
layer.height *= scaleFactor;
layer.x += (oldWidth - layer.width) / 2;
layer.y += (oldHeight - layer.height) / 2;
// Handle wheel scaling end for layers with blend area
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
}
}
calculateGridBasedScaling(oldHeight, deltaY) {
const gridSize = 64;
const gridSize = 64; // Grid size - could be made configurable in the future
const direction = deltaY > 0 ? -1 : 1;
let targetHeight;
if (direction > 0) {
@@ -391,6 +455,8 @@ export class CanvasInteractions {
handleKeyDown(e) {
if (e.key === 'Control')
this.interaction.isCtrlPressed = true;
if (e.key === 'Meta')
this.interaction.isMetaPressed = true;
if (e.key === 'Shift')
this.interaction.isShiftPressed = true;
if (e.key === 'Alt') {
@@ -408,11 +474,12 @@ export class CanvasInteractions {
return;
}
// Globalne skróty (Undo/Redo/Copy/Paste)
if (e.ctrlKey || e.metaKey) {
const mods = this.getModifierState(e);
if (mods.ctrl || mods.meta) {
let handled = true;
switch (e.key.toLowerCase()) {
case 'z':
if (e.shiftKey) {
if (mods.shift) {
this.canvas.redo();
}
else {
@@ -439,7 +506,7 @@ export class CanvasInteractions {
}
// Skróty kontekstowe (zależne od zaznaczenia)
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const step = e.shiftKey ? 10 : 1;
const step = mods.shift ? 10 : 1;
let needsRender = false;
// Używamy e.code dla spójności i niezależności od układu klawiatury
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
@@ -475,6 +542,8 @@ export class CanvasInteractions {
handleKeyUp(e) {
if (e.key === 'Control')
this.interaction.isCtrlPressed = false;
if (e.key === 'Meta')
this.interaction.isMetaPressed = false;
if (e.key === 'Shift')
this.interaction.isShiftPressed = false;
if (e.key === 'Alt')
@@ -494,6 +563,7 @@ export class CanvasInteractions {
handleBlur() {
log.debug('Window lost focus, resetting key states.');
this.interaction.isCtrlPressed = false;
this.interaction.isMetaPressed = false;
this.interaction.isAltPressed = false;
this.interaction.isShiftPressed = false;
this.interaction.isSPressed = false;
@@ -515,6 +585,11 @@ export class CanvasInteractions {
}
}
updateCursor(worldCoords) {
// If actively rotating, show grabbing cursor
if (this.interaction.mode === 'rotating') {
this.canvas.canvas.style.cursor = 'grabbing';
return;
}
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) {
const handleName = transformTarget.handle;
@@ -562,7 +637,9 @@ export class CanvasInteractions {
}
prepareForDrag(layer, worldCoords) {
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
if (this.interaction.isCtrlPressed) {
// Support both Ctrl (Windows/Linux) and Cmd (macOS) for multi-selection
const mods = this.getModifierState();
if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) {
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
@@ -580,10 +657,9 @@ export class CanvasInteractions {
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = { ...worldCoords };
}
startPanningOrClearSelection(e) {
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
if (!this.interaction.isCtrlPressed) {
startPanning(e, clearSelection = true) {
// Unified panning method - can optionally clear selection
if (clearSelection && !this.interaction.isCtrlPressed) {
this.canvas.canvasSelection.updateSelection([]);
}
this.interaction.mode = 'panning';
@@ -632,19 +708,16 @@ export class CanvasInteractions {
this.canvas.render();
this.canvas.saveState();
}
startPanning(e) {
if (!this.interaction.isCtrlPressed) {
this.canvas.canvasSelection.updateSelection([]);
}
this.interaction.mode = 'panning';
this.interaction.panStart = { x: e.clientX, y: e.clientY };
}
panViewport(e) {
const dx = e.clientX - this.interaction.panStart.x;
const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = { x: e.clientX, y: e.clientY };
// Update stroke overlay if mask tool is drawing during pan
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.render();
this.canvas.onViewportChange?.();
}
@@ -699,7 +772,7 @@ export class CanvasInteractions {
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
}
const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
if (!o)
return;
const handle = this.interaction.resizeHandle;
const anchor = this.interaction.resizeAnchor;
@@ -732,8 +805,14 @@ export class CanvasInteractions {
// Rotate mouse delta into the layer's unrotated frame
const deltaX_world = mouseX_local - dragStartX_local;
const deltaY_world = mouseY_local - dragStartY_local;
const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
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;
@@ -741,19 +820,39 @@ export class CanvasInteractions {
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')) {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
if (isFlippedH)
newCropBounds.width += delta_image_x;
else {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
}
}
if (handle?.includes('e')) {
newCropBounds.width += delta_image_x;
if (isFlippedH) {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
}
else
newCropBounds.width += delta_image_x;
}
if (handle?.includes('n')) {
newCropBounds.y += delta_image_y;
newCropBounds.height -= delta_image_y;
if (isFlippedV)
newCropBounds.height += delta_image_y;
else {
newCropBounds.y += delta_image_y;
newCropBounds.height -= delta_image_y;
}
}
if (handle?.includes('s')) {
newCropBounds.height += delta_image_y;
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) {
@@ -820,7 +919,7 @@ export class CanvasInteractions {
if (!layer)
return;
const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined)
if (!o)
return;
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);

View File

@@ -3,6 +3,7 @@ import { createModuleLogger } from "./utils/LoggerUtils.js";
import { generateUUID, generateUniqueFileName, createCanvas } from "./utils/CommonUtils.js";
import { withErrorHandling, createValidationError } from "./ErrorHandler.js";
import { showErrorNotification } from "./utils/NotificationUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
// @ts-ignore
import { app } from "../../scripts/app.js";
// @ts-ignore
@@ -16,6 +17,17 @@ export class CanvasLayers {
this.blendMenuElement = null;
this.blendMenuWorldX = 0;
this.blendMenuWorldY = 0;
// Cache for processed images with effects applied
this.processedImageCache = new Map();
// Debouncing system for processed image creation
this.processedImageDebounceTimers = new Map();
this.PROCESSED_IMAGE_DEBOUNCE_DELAY = 1000; // 1 second
this.globalDebounceTimer = null;
this.lastRenderTime = 0;
this.layersAdjustingBlendArea = new Set();
this.layersTransformingCropBounds = new Set();
this.layersTransformingScale = new Set();
this.layersWheelScaling = new Set();
this.addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => {
if (!image) {
throw createValidationError("Image is required for layer creation");
@@ -103,6 +115,8 @@ export class CanvasLayers {
this.canvas = canvas;
this.clipboardManager = new ClipboardManager(canvas);
this.distanceFieldCache = new WeakMap();
this.processedImageCache = new Map();
this.processedImageDebounceTimers = new Map();
this.blendModes = [
{ name: 'normal', label: 'Normal' },
{ name: 'multiply', label: 'Multiply' },
@@ -122,6 +136,8 @@ export class CanvasLayers {
this.isAdjustingOpacity = false;
this.internalClipboard = [];
this.clipboardPreference = 'system';
// Load CSS for blend mode menu
addStylesheet(getUrl('./css/blend_mode_menu.css'));
}
async copySelectedLayers() {
if (this.canvas.canvasSelection.selectedLayers.length === 0)
@@ -310,7 +326,10 @@ export class CanvasLayers {
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.width *= scale;
layer.height *= scale;
this.invalidateBlendCache(layer);
// Invalidate processed image cache when layer dimensions change
this.invalidateProcessedImageCache(layer.id);
// Handle wheel scaling end for layers with blend area
this.handleWheelScalingEnd(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
@@ -320,7 +339,6 @@ export class CanvasLayers {
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.rotation += angle;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
@@ -366,31 +384,99 @@ export class CanvasLayers {
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// Check if we need to apply blend area effect
const blendArea = layer.blendArea ?? 0;
const needsBlendAreaEffect = blendArea > 0;
if (needsBlendAreaEffect) {
// Check if we have a valid cached blended image
if (layer.blendedImageCache && !layer.blendedImageDirty) {
// Use cached blended image for optimal performance
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
}
else {
// Cache is invalid or doesn't exist, update it
this.updateLayerBlendEffect(layer);
// Use the newly created cache if available, otherwise fallback
if (layer.blendedImageCache) {
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
}
else {
// Fallback to normal drawing
this._drawLayerImage(ctx, layer);
// Check if we should render blend area live only in specific cases:
// 1. When user is actively resizing in crop mode (transforming crop bounds) - only for the specific layer being transformed
// 2. When user is actively resizing in transform mode (scaling layer) - only for the specific layer being transformed
// 3. When blend area slider is being adjusted - only for the layer that has the menu open
// 4. When layer is in the transforming crop bounds set (continues live rendering until cache is ready)
// 5. When layer is in the transforming scale set (continues live rendering until cache is ready)
const isTransformingCropBounds = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' &&
this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id &&
layer.cropMode;
// Check if user is actively scaling this layer in transform mode (not crop mode)
const isTransformingScale = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' &&
this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id &&
!layer.cropMode;
// Check if this specific layer is the one being adjusted in blend area slider
const isThisLayerBeingAdjusted = this.layersAdjustingBlendArea.has(layer.id);
// Check if this layer is in the transforming crop bounds set (continues live rendering until cache is ready)
const isTransformingCropBoundsSet = this.layersTransformingCropBounds.has(layer.id);
// Check if this layer is in the transforming scale set (continues live rendering until cache is ready)
const isTransformingScaleSet = this.layersTransformingScale.has(layer.id);
// Check if this layer is being scaled by wheel or buttons (continues live rendering until cache is ready)
const isWheelScaling = this.layersWheelScaling.has(layer.id);
const shouldRenderLive = isTransformingCropBounds || isTransformingScale || isThisLayerBeingAdjusted || isTransformingCropBoundsSet || isTransformingScaleSet || isWheelScaling;
// Check if we should use cached processed image or render live
const processedImage = this.getProcessedImage(layer);
// For scaling operations, try to find the BEST matching cache for this layer
let bestMatchingCache = null;
if (isTransformingScale || isTransformingScaleSet || isWheelScaling) {
// Look for cache entries that match the current layer state as closely as possible
const currentCacheKey = this.getProcessedImageCacheKey(layer);
const currentBlendArea = layer.blendArea ?? 0;
const currentCropKey = layer.cropBounds ?
`${layer.cropBounds.x},${layer.cropBounds.y},${layer.cropBounds.width},${layer.cropBounds.height}` :
'nocrop';
// Score each cache entry to find the best match
let bestScore = -1;
for (const [key, image] of this.processedImageCache.entries()) {
if (key.startsWith(layer.id + '_')) {
let score = 0;
// Extract blend area and crop info from cache key
const keyParts = key.split('_');
if (keyParts.length >= 3) {
const cacheBlendArea = parseInt(keyParts[1]);
const cacheCropKey = keyParts[2];
// Score based on blend area match (higher priority)
if (cacheBlendArea === currentBlendArea) {
score += 100;
}
else {
score -= Math.abs(cacheBlendArea - currentBlendArea);
}
// Score based on crop match (high priority)
if (cacheCropKey === currentCropKey) {
score += 200;
}
else {
// Penalize mismatched crop states heavily
score -= 150;
}
// Small bonus for exact match
if (key === currentCacheKey) {
score += 50;
}
}
if (score > bestScore) {
bestScore = score;
bestMatchingCache = image;
log.debug(`Better cache found for layer ${layer.id}: ${key} (score: ${score})`);
}
}
}
if (bestMatchingCache) {
log.debug(`Using best matching cache for layer ${layer.id} during scaling`);
}
}
if (processedImage && !shouldRenderLive) {
// Use cached processed image for all cases except specific live rendering scenarios
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(processedImage, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
}
else if (bestMatchingCache && (isTransformingScale || isTransformingScaleSet || isWheelScaling)) {
// During scaling operations: use the BEST matching processed image (more efficient)
// This ensures we always use the most appropriate blend area image during scaling
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(bestMatchingCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
}
else if (needsBlendAreaEffect && shouldRenderLive && !isWheelScaling) {
// Render blend area live only when transforming crop bounds or adjusting blend area slider
// BUT NOT during wheel scaling - that should use cached image
this._drawLayerWithLiveBlendArea(ctx, layer);
}
else {
// Normal drawing without blend area effect
@@ -398,14 +484,19 @@ export class CanvasLayers {
}
ctx.restore();
}
_drawLayerImage(ctx, layer) {
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
/**
* Zunifikowana funkcja do rysowania obrazu warstwy z crop
* @param ctx Canvas context
* @param layer Warstwa do narysowania
* @param offsetX Przesunięcie X względem środka warstwy (domyślnie -width/2)
* @param offsetY Przesunięcie Y względem środka warstwy (domyślnie -height/2)
*/
drawLayerImageWithCrop(ctx, layer, offsetX = -layer.width / 2, offsetY = -layer.height / 2) {
// Use cropBounds if they exist, otherwise use the full image dimensions as the source
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
if (!layer.originalWidth || !layer.originalHeight) {
// Fallback for older layers without original dimensions or if data is missing
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.drawImage(layer.image, offsetX, offsetY, layer.width, layer.height);
return;
}
// Calculate the on-screen scale of the layer's transform frame
@@ -414,100 +505,377 @@ export class CanvasLayers {
// 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.
// This is relative to the layer's center (the context's 0,0).
const dX = (-layer.width / 2) + (s.x * layerScaleX);
const dY = (-layer.height / 2) + (s.y * 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 within the transform frame)
dX, dY, dWidth, dHeight // destination rect (scaled and positioned)
);
}
/**
* Invalidates the blended image cache for a layer
*/
invalidateBlendCache(layer) {
layer.blendedImageDirty = true;
layer.blendedImageCache = undefined;
_drawLayerImage(ctx, layer) {
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
this.drawLayerImageWithCrop(ctx, layer);
}
/**
* Updates the blended image cache for a layer with blendArea effect
* 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
*/
updateLayerBlendEffect(layer) {
createBlendAreaMask(layer) {
const blendArea = layer.blendArea ?? 0;
if (blendArea <= 0) {
// No blend effect needed, clear cache
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
return;
}
try {
log.debug(`Updating blend effect cache for layer ${layer.id}, blendArea: ${blendArea}%`);
// Create the blended image using the same logic as _drawLayer
let maskCanvas = null;
let maskWidth = layer.width;
let maskHeight = layer.height;
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
maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea);
maskWidth = s.width;
maskHeight = s.height;
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
maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
maskWidth = layer.originalWidth || layer.width;
maskHeight = layer.originalHeight || layer.height;
}
}
else {
// No crop, use full image
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
if (maskCanvas) {
// Create the final blended canvas
const { canvas: blendedCanvas, ctx: blendedCtx } = createCanvas(layer.width, layer.height);
if (blendedCtx) {
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
if (!layer.originalWidth || !layer.originalHeight) {
blendedCtx.drawImage(layer.image, 0, 0, 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 = s.x * layerScaleX;
const dY = s.y * layerScaleY;
blendedCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, dX, dY, dWidth, dHeight);
// Apply the distance field mask only to the visible (cropped) area
blendedCtx.globalCompositeOperation = 'destination-in';
// Scale the mask to match the drawn area
blendedCtx.drawImage(maskCanvas, 0, 0, maskWidth, maskHeight, dX, dY, dWidth, dHeight);
}
// Store the blended result in cache
layer.blendedImageCache = blendedCanvas;
layer.blendedImageDirty = false;
log.debug(`Blend effect cache updated for layer ${layer.id}`);
}
else {
log.warn(`Failed to create blended canvas context for layer ${layer.id}`);
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
}
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 {
log.warn(`Failed to create distance field mask for layer ${layer.id}`);
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
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);
}
}
catch (error) {
log.error(`Error updating blend effect for layer ${layer.id}:`, error);
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
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 {
const processedImage = this.createProcessedImage(layer);
if (processedImage) {
this.processedImageCache.set(cacheKey, processedImage);
log.debug(`Cached debounced processed image for layer ${layer.id}`);
// Trigger re-render to show the processed image
this.canvas.render();
}
}
catch (error) {
log.error('Failed to create 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;
@@ -578,7 +946,6 @@ export class CanvasLayers {
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.flipH = !layer.flipH;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
@@ -588,24 +955,19 @@ export class CanvasLayers {
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.flipV = !layer.flipV;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
}
async getLayerImageData(layer) {
try {
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height, '2d', { willReadFrequently: true });
const width = layer.originalWidth || layer.width;
const height = layer.originalHeight || layer.height;
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(width, height, '2d', { willReadFrequently: true });
if (!tempCtx)
throw new Error("Could not create canvas context");
// We need to draw the layer relative to the new canvas, so we "move" it to 0,0
// by creating a temporary layer object for drawing.
const layerToDraw = {
...layer,
x: 0,
y: 0,
};
this._drawLayer(tempCtx, layerToDraw);
// Use original image directly to ensure full quality
tempCtx.drawImage(layer.image, 0, 0, width, height);
const dataUrl = tempCanvas.toDataURL('image/png');
if (!dataUrl.startsWith('data:image/png;base64,')) {
throw new Error("Invalid image data format");
@@ -674,9 +1036,16 @@ export class CanvasLayers {
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) + ((layer.cropBounds.x + layer.cropBounds.width / 2) * layerScaleX);
const cropCenterY_local = (-layer.height / 2) + ((layer.cropBounds.y + layer.cropBounds.height / 2) * layerScaleY);
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);
@@ -785,65 +1154,14 @@ export class CanvasLayers {
const menu = document.createElement('div');
this.blendMenuElement = menu;
menu.id = 'blend-mode-menu';
menu.style.cssText = `
position: absolute;
top: 0;
left: 0;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
z-index: 10000;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
min-width: 200px;
`;
const titleBar = document.createElement('div');
titleBar.style.cssText = `
background: #3a3a3a;
color: white;
padding: 8px 10px;
cursor: move;
user-select: none;
border-radius: 3px 3px 0 0;
font-size: 12px;
font-weight: bold;
border-bottom: 1px solid #4a4a4a;
display: flex;
justify-content: space-between;
align-items: center;
`;
titleBar.className = 'blend-menu-title-bar';
const titleText = document.createElement('span');
titleText.textContent = `Blend Mode: ${selectedLayer.name}`;
titleText.style.cssText = `
flex: 1;
cursor: move;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
titleText.className = 'blend-menu-title-text';
const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.style.cssText = `
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
margin: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: background-color 0.2s;
`;
closeButton.onmouseover = () => {
closeButton.style.backgroundColor = '#4a4a4a';
};
closeButton.onmouseout = () => {
closeButton.style.backgroundColor = 'transparent';
};
closeButton.className = 'blend-menu-close-button';
closeButton.onclick = (e) => {
e.stopPropagation();
this.closeBlendModeMenu();
@@ -851,32 +1169,54 @@ export class CanvasLayers {
titleBar.appendChild(titleText);
titleBar.appendChild(closeButton);
const content = document.createElement('div');
content.style.cssText = `padding: 5px;`;
content.className = 'blend-menu-content';
menu.appendChild(titleBar);
menu.appendChild(content);
const blendAreaContainer = document.createElement('div');
blendAreaContainer.style.cssText = `padding: 5px 10px; border-bottom: 1px solid #4a4a4a;`;
blendAreaContainer.className = 'blend-area-container';
const blendAreaLabel = document.createElement('label');
blendAreaLabel.textContent = 'Blend Area';
blendAreaLabel.style.color = 'white';
blendAreaLabel.className = 'blend-area-label';
const blendAreaSlider = document.createElement('input');
blendAreaSlider.type = 'range';
blendAreaSlider.min = '0';
blendAreaSlider.max = '100';
blendAreaSlider.className = 'blend-area-slider';
blendAreaSlider.value = selectedLayer?.blendArea?.toString() ?? '0';
blendAreaSlider.oninput = () => {
if (selectedLayer) {
const newValue = parseInt(blendAreaSlider.value, 10);
selectedLayer.blendArea = newValue;
// Invalidate cache when blend area changes
this.invalidateBlendCache(selectedLayer);
// Set flag to enable live blend area rendering for this specific layer
this.layersAdjustingBlendArea.add(selectedLayer.id);
// Invalidate processed image cache when blend area changes
this.invalidateProcessedImageCache(selectedLayer.id);
this.canvas.render();
}
};
blendAreaSlider.addEventListener('change', () => {
// When user stops adjusting, create cache asynchronously but keep live rendering until cache is ready
if (selectedLayer) {
// Update the blend effect cache when the slider value is finalized
this.updateLayerBlendEffect(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();
});
@@ -912,20 +1252,19 @@ export class CanvasLayers {
this.blendModes.forEach((mode) => {
const container = document.createElement('div');
container.className = 'blend-mode-container';
container.style.cssText = `margin-bottom: 5px;`;
const option = document.createElement('div');
option.style.cssText = `padding: 5px 10px; color: white; cursor: pointer; transition: background-color 0.2s;`;
option.className = 'blend-mode-option';
option.textContent = `${mode.label} (${mode.name})`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '100';
slider.className = 'blend-opacity-slider';
const selectedLayer = this.canvas.canvasSelection.selectedLayers[0];
slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100';
slider.style.cssText = `width: 100%; margin: 5px 0; display: none;`;
if (selectedLayer && selectedLayer.blendMode === mode.name) {
slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a';
container.classList.add('active');
option.classList.add('active');
}
option.onclick = () => {
// Re-check selected layer at the time of click
@@ -933,19 +1272,17 @@ export class CanvasLayers {
if (!currentSelectedLayer) {
return;
}
// Hide only the opacity sliders within other blend mode containers
// Remove active class from all containers and options
content.querySelectorAll('.blend-mode-container').forEach(c => {
const opacitySlider = c.querySelector('input[type="range"]');
if (opacitySlider) {
opacitySlider.style.display = 'none';
}
const optionDiv = c.querySelector('div');
c.classList.remove('active');
const optionDiv = c.querySelector('.blend-mode-option');
if (optionDiv) {
optionDiv.style.backgroundColor = '';
optionDiv.classList.remove('active');
}
});
slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a';
// Add active class to current container and option
container.classList.add('active');
option.classList.add('active');
currentSelectedLayer.blendMode = mode.name;
this.canvas.render();
};

View File

@@ -1,6 +1,7 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { createCanvas } from "./utils/CommonUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
const log = createModuleLogger('CanvasLayersPanel');
export class CanvasLayersPanel {
constructor(canvas) {
@@ -18,6 +19,8 @@ export class CanvasLayersPanel {
this.handleDrop = this.handleDrop.bind(this);
// Preload icons
this.initializeIcons();
// Load CSS for layers panel
addStylesheet(getUrl('./css/layers_panel.css'));
log.info('CanvasLayersPanel initialized');
}
async initializeIcons() {
@@ -31,22 +34,15 @@ export class CanvasLayersPanel {
}
createIconElement(toolName, size = 16) {
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `
width: ${size}px;
height: ${size}px;
display: flex;
align-items: center;
justify-content: center;
`;
iconContainer.className = 'icon-container';
iconContainer.style.width = `${size}px`;
iconContainer.style.height = `${size}px`;
const icon = iconLoader.getIcon(toolName);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode();
img.style.cssText = `
width: ${size}px;
height: ${size}px;
filter: brightness(0) invert(1);
`;
img.style.width = `${size}px`;
img.style.height = `${size}px`;
iconContainer.appendChild(img);
}
else if (icon instanceof HTMLCanvasElement) {
@@ -59,9 +55,9 @@ export class CanvasLayersPanel {
}
else {
// Fallback text
iconContainer.classList.add('fallback-text');
iconContainer.textContent = toolName.charAt(0).toUpperCase();
iconContainer.style.fontSize = `${size * 0.6}px`;
iconContainer.style.color = '#ffffff';
}
return iconContainer;
}
@@ -72,24 +68,15 @@ export class CanvasLayersPanel {
else {
// Create a "hidden" version of the visibility icon
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
`;
iconContainer.className = 'icon-container visibility-hidden';
iconContainer.style.width = '16px';
iconContainer.style.height = '16px';
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode();
img.style.cssText = `
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
opacity: 0.3;
`;
img.style.width = '16px';
img.style.height = '16px';
iconContainer.appendChild(img);
}
else if (icon instanceof HTMLCanvasElement) {
@@ -103,9 +90,9 @@ export class CanvasLayersPanel {
}
else {
// Fallback
iconContainer.classList.add('fallback-text');
iconContainer.textContent = 'H';
iconContainer.style.fontSize = '10px';
iconContainer.style.color = '#888888';
}
return iconContainer;
}
@@ -126,7 +113,6 @@ export class CanvasLayersPanel {
</div>
`;
this.layersContainer = this.container.querySelector('#layers-container');
this.injectStyles();
// Setup event listeners dla przycisków
this.setupControlButtons();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
@@ -140,212 +126,6 @@ export class CanvasLayersPanel {
log.debug('Panel structure created');
return this.container;
}
injectStyles() {
const styleId = 'layers-panel-styles';
if (document.getElementById(styleId)) {
return; // Style już istnieją
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.layers-panel {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 8px;
height: 100%;
overflow: hidden;
font-family: Arial, sans-serif;
font-size: 12px;
color: #ffffff;
user-select: none;
display: flex;
flex-direction: column;
}
.layers-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #3a3a3a;
margin-bottom: 8px;
}
.layers-panel-title {
font-weight: bold;
color: #ffffff;
}
.layers-panel-controls {
display: flex;
gap: 4px;
}
.layers-btn {
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ffffff;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.layers-btn:hover {
background: #4a4a4a;
}
.layers-btn:active {
background: #5a5a5a;
}
.layers-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.layer-row {
display: flex;
align-items: center;
padding: 6px 4px;
margin-bottom: 2px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s ease;
position: relative;
gap: 6px;
}
.layer-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.layer-row.selected {
background: #2d5aa0 !important;
box-shadow: inset 0 0 0 1px #4a7bc8;
}
.layer-row.dragging {
opacity: 0.6;
}
.layer-thumbnail {
width: 48px;
height: 48px;
border: 1px solid #4a4a4a;
border-radius: 2px;
background: transparent;
position: relative;
flex-shrink: 0;
overflow: hidden;
}
.layer-thumbnail canvas {
width: 100%;
height: 100%;
display: block;
}
.layer-thumbnail::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
z-index: 1;
}
.layer-thumbnail canvas {
position: relative;
z-index: 2;
}
.layer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 4px;
border-radius: 2px;
color: #ffffff;
}
.layer-name.editing {
background: #4a4a4a;
border: 1px solid #6a6a6a;
outline: none;
color: #ffffff;
}
.layer-name input {
background: transparent;
border: none;
color: #ffffff;
font-size: 12px;
width: 100%;
outline: none;
}
.drag-insertion-line {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #4a7bc8;
border-radius: 1px;
z-index: 1000;
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
}
.layers-container::-webkit-scrollbar {
width: 6px;
}
.layers-container::-webkit-scrollbar-track {
background: #2a2a2a;
}
.layers-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
.layer-visibility-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 2px;
font-size: 14px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.layer-visibility-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
`;
document.head.appendChild(style);
log.debug('Styles injected');
}
setupControlButtons() {
if (!this.container)
return;
@@ -359,6 +139,8 @@ export class CanvasLayersPanel {
log.info('Delete layer button clicked');
this.deleteSelectedLayers();
});
// Initial button state update
this.updateButtonStates();
}
renderLayers() {
if (!this.layersContainer) {
@@ -448,6 +230,7 @@ export class CanvasLayersPanel {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
this.canvas.updateSelection(newSelection);
this.updateSelectionAppearance();
this.updateButtonStates();
}
});
layerRow.addEventListener('dblclick', (e) => {
@@ -480,6 +263,7 @@ export class CanvasLayersPanel {
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance();
this.updateButtonStates();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
}
startEditingLayerName(nameElement, layer) {
@@ -660,12 +444,29 @@ export class CanvasLayersPanel {
}
});
}
/**
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
*/
updateButtonStates() {
if (!this.container)
return;
const deleteBtn = this.container.querySelector('#delete-layer-btn');
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
if (deleteBtn) {
deleteBtn.disabled = !hasSelectedLayers;
deleteBtn.title = hasSelectedLayers
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
: 'No layers selected';
}
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
}
/**
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
*/
onSelectionChanged() {
this.updateSelectionAppearance();
this.updateButtonStates();
}
destroy() {
if (this.container && this.container.parentNode) {

View File

@@ -7,6 +7,9 @@ export class CanvasRenderer {
this.lastRenderTime = 0;
this.renderInterval = 1000 / 60;
this.isDirty = false;
// Initialize overlay canvases
this.initOverlay();
this.initStrokeOverlay();
}
/**
* Helper function to draw text with background at world coordinates
@@ -102,10 +105,12 @@ export class CanvasRenderer {
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
ctx.save();
if (this.canvas.maskTool.isActive) {
// In draw mask mode, use the previewOpacity value from the slider
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.5;
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
}
else {
// When not in draw mask mode, show mask at full opacity
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0;
}
@@ -158,6 +163,11 @@ export class CanvasRenderer {
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
}
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
// Ensure overlay canvases are in DOM and properly sized
this.addOverlayToDOM();
this.updateOverlaySize();
this.addStrokeOverlayToDOM();
this.updateStrokeOverlaySize();
// Update Batch Preview UI positions
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager) => {
@@ -467,8 +477,10 @@ export class CanvasRenderer {
// Draw line to rotation handle
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(0, -halfH);
ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom);
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) ---
@@ -476,19 +488,29 @@ export class CanvasRenderer {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
for (const key in handles) {
// Skip rotation handle in crop mode
if (layer.cropMode && key === 'rot')
continue;
const point = handles[key];
// The handle position is already in world space, we need it in the layer's rotated space
const localX = point.x - (layer.x + layer.width / 2);
const localY = point.y - (layer.y + layer.height / 2);
// The handle position is already in world space.
// We need to convert it to the layer's local, un-rotated space.
const dx = point.x - centerX;
const dy = point.y - centerY;
// "Un-rotate" the position to get it in the layer's local, un-rotated space
const rad = -layer.rotation * Math.PI / 180;
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// 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(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
@@ -571,4 +593,243 @@ export class CanvasRenderer {
padding: 8
});
}
/**
* Initialize overlay canvas for lightweight overlays like brush cursor
*/
initOverlay() {
// Setup overlay canvas to match main canvas
this.updateOverlaySize();
// Position overlay canvas on top of main canvas
this.canvas.overlayCanvas.style.position = 'absolute';
this.canvas.overlayCanvas.style.left = '0px';
this.canvas.overlayCanvas.style.top = '0px';
this.canvas.overlayCanvas.style.pointerEvents = 'none';
this.canvas.overlayCanvas.style.zIndex = '20'; // Above other overlays
// Add overlay to DOM when main canvas is added
this.addOverlayToDOM();
log.debug('Overlay canvas initialized');
}
/**
* Add overlay canvas to DOM if main canvas has a parent
*/
addOverlayToDOM() {
if (this.canvas.canvas.parentElement && !this.canvas.overlayCanvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.canvas.overlayCanvas);
log.debug('Overlay canvas added to DOM');
}
}
/**
* Update overlay canvas size to match main canvas
*/
updateOverlaySize() {
if (this.canvas.overlayCanvas.width !== this.canvas.canvas.clientWidth ||
this.canvas.overlayCanvas.height !== this.canvas.canvas.clientHeight) {
this.canvas.overlayCanvas.width = Math.max(1, this.canvas.canvas.clientWidth);
this.canvas.overlayCanvas.height = Math.max(1, this.canvas.canvas.clientHeight);
log.debug(`Overlay canvas resized to ${this.canvas.overlayCanvas.width}x${this.canvas.overlayCanvas.height}`);
}
}
/**
* Clear overlay canvas
*/
clearOverlay() {
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
}
/**
* Initialize a dedicated overlay for real-time mask stroke preview
*/
initStrokeOverlay() {
// Create canvas if not created yet
if (!this.strokeOverlayCanvas) {
this.strokeOverlayCanvas = document.createElement('canvas');
const ctx = this.strokeOverlayCanvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2D context for stroke overlay canvas');
}
this.strokeOverlayCtx = ctx;
}
// Size match main canvas
this.updateStrokeOverlaySize();
// Position above main canvas but below cursor overlay
this.strokeOverlayCanvas.style.position = 'absolute';
this.strokeOverlayCanvas.style.left = '1px';
this.strokeOverlayCanvas.style.top = '1px';
this.strokeOverlayCanvas.style.pointerEvents = 'none';
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
// Opacity is now controlled by MaskTool.previewOpacity
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
// Add to DOM
this.addStrokeOverlayToDOM();
log.debug('Stroke overlay canvas initialized');
}
/**
* Add stroke overlay canvas to DOM if needed
*/
addStrokeOverlayToDOM() {
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
log.debug('Stroke overlay canvas added to DOM');
}
}
/**
* Ensure stroke overlay size matches main canvas
*/
updateStrokeOverlaySize() {
const w = Math.max(1, this.canvas.canvas.clientWidth);
const h = Math.max(1, this.canvas.canvas.clientHeight);
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
this.strokeOverlayCanvas.width = w;
this.strokeOverlayCanvas.height = h;
log.debug(`Stroke overlay resized to ${w}x${h}`);
}
}
/**
* Clear the stroke overlay
*/
clearMaskStrokeOverlay() {
if (!this.strokeOverlayCtx)
return;
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
}
/**
* Draw a preview stroke segment onto the stroke overlay in screen space
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
*/
drawMaskStrokeSegment(startWorld, endWorld) {
// Ensure overlay is present and sized
this.updateStrokeOverlaySize();
const zoom = this.canvas.viewport.zoom;
const toScreen = (p) => ({
x: (p.x - this.canvas.viewport.x) * zoom,
y: (p.y - this.canvas.viewport.y) * zoom
});
const startScreen = toScreen(startWorld);
const endScreen = toScreen(endWorld);
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
const hardness = this.canvas.maskTool.brushHardness;
const strength = this.canvas.maskTool.brushStrength;
// If strength is 0, don't draw anything
if (strength <= 0) {
return;
}
this.strokeOverlayCtx.save();
// Draw line segment exactly as MaskTool does
this.strokeOverlayCtx.beginPath();
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
// Match the gradient setup from MaskTool's drawLineOnChunk
if (hardness === 1) {
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
}
else {
const innerRadius = brushRadius * hardness;
const gradient = this.strokeOverlayCtx.createRadialGradient(endScreen.x, endScreen.y, innerRadius, endScreen.x, endScreen.y, brushRadius);
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
this.strokeOverlayCtx.strokeStyle = gradient;
}
// Match line properties from MaskTool
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
this.strokeOverlayCtx.lineCap = 'round';
this.strokeOverlayCtx.lineJoin = 'round';
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
this.strokeOverlayCtx.stroke();
this.strokeOverlayCtx.restore();
}
/**
* Redraws the entire stroke overlay from world coordinates
* Used when viewport changes during drawing to maintain visual consistency
*/
redrawMaskStrokeOverlay(strokePoints) {
if (strokePoints.length < 2)
return;
// Clear the overlay first
this.clearMaskStrokeOverlay();
// Redraw all segments with current viewport
for (let i = 1; i < strokePoints.length; i++) {
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
}
}
/**
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
* @param worldPoint World coordinates of cursor
*/
drawMaskBrushCursor(worldPoint) {
if (!this.canvas.maskTool.isActive || !this.canvas.isMouseOver) {
this.clearOverlay();
return;
}
// Update overlay size if needed
this.updateOverlaySize();
// Clear previous cursor
this.clearOverlay();
// Convert world coordinates to screen coordinates
const screenX = (worldPoint.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (worldPoint.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
// Get brush properties
const brushRadius = (this.canvas.maskTool.brushSize / 2) * this.canvas.viewport.zoom;
const brushStrength = this.canvas.maskTool.brushStrength;
const brushHardness = this.canvas.maskTool.brushHardness;
// Save context state
this.canvas.overlayCtx.save();
// If strength is 0, just draw outline
if (brushStrength > 0) {
// Draw inner fill to visualize brush effect - matches actual brush rendering
const gradient = this.canvas.overlayCtx.createRadialGradient(screenX, screenY, 0, screenX, screenY, brushRadius);
// Preview alpha - subtle to not obscure content
const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
if (brushHardness === 1) {
// Hard brush - uniform fill within radius
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
}
else {
// Soft brush - gradient fade matching actual brush
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
if (brushHardness > 0) {
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
}
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
}
this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
this.canvas.overlayCtx.fillStyle = gradient;
this.canvas.overlayCtx.fill();
}
// Draw outer circle (SIZE indicator)
this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
// Stroke opacity based on strength (dimmer when strength is 0)
const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
this.canvas.overlayCtx.lineWidth = 1.5;
// Visual feedback for hardness
if (brushHardness > 0.8) {
// Hard brush - solid line
this.canvas.overlayCtx.setLineDash([]);
}
else {
// Soft brush - dashed line
const dashLength = 2 + (1 - brushHardness) * 4;
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
}
this.canvas.overlayCtx.stroke();
// Center dot for small brushes
if (brushRadius < 5) {
this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
this.canvas.overlayCtx.fill();
}
// Restore context state
this.canvas.overlayCtx.restore();
}
/**
* Update overlay position when viewport changes
*/
updateOverlayPosition() {
// Overlay canvas is positioned absolutely, so it doesn't need repositioning
// Just ensure it's the right size
this.updateOverlaySize();
}
}

View File

@@ -286,9 +286,6 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
const newLayer = { ...layer, imageId: layer.imageId || '' };
delete newLayer.image;
// Remove cache properties that cannot be serialized for the worker
delete newLayer.blendedImageCache;
delete newLayer.blendedImageDirty;
if (layer.image instanceof HTMLImageElement) {
if (layer.imageId) {
newLayer.imageId = layer.imageId;

View File

@@ -98,7 +98,6 @@ async function createCanvasWidget(node, widget, app) {
}),
$el("button.painter-button.icon-button", {
textContent: "?",
title: "Show shortcuts",
onmouseenter: (e) => {
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
showTooltip(e.target, content);
@@ -177,10 +176,22 @@ async function createCanvasWidget(node, widget, app) {
$el("span.switch-icon")
])
]);
// Helper function to get current tooltip content based on switch state
const getCurrentTooltipContent = () => {
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
return checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
};
// Helper function to update tooltip content if it's currently visible
const updateTooltipIfVisible = () => {
// Only update if tooltip is currently visible
if (helpTooltip.style.display === 'block') {
const tooltipContent = getCurrentTooltipContent();
showTooltip(switchEl, tooltipContent);
}
};
// Tooltip logic
switchEl.addEventListener("mouseenter", (e) => {
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
const tooltipContent = getCurrentTooltipContent();
showTooltip(switchEl, tooltipContent);
});
switchEl.addEventListener("mouseleave", hideTooltip);
@@ -189,6 +200,8 @@ async function createCanvasWidget(node, widget, app) {
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
input.addEventListener('change', () => {
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
// Update tooltip content immediately after state change
updateTooltipIfVisible();
});
// Initial state
iconLoader.preloadToolIcons().then(() => {
@@ -417,6 +430,8 @@ async function createCanvasWidget(node, widget, app) {
delete newLayer.imageId;
canvas.layers[selectedLayerIndex] = newLayer;
canvas.canvasSelection.updateSelection([newLayer]);
// Invalidate processed image cache when layer image changes (matting)
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
canvas.render();
canvas.saveState();
showSuccessNotification("Background removed successfully!");
@@ -453,7 +468,8 @@ async function createCanvasWidget(node, widget, app) {
$el("div.painter-button-group", { id: "mask-controls" }, [
$el("label.clipboard-switch.mask-switch", {
id: `toggle-mask-switch-${node.id}`,
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" },
title: "Toggle mask overlay visibility on canvas (mask still affects output when disabled)"
}, [
$el("input", {
type: "checkbox",
@@ -538,6 +554,25 @@ async function createCanvasWidget(node, widget, app) {
setTimeout(() => canvas.render(), 0);
}
}),
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
$el("label", { for: "preview-opacity-slider", textContent: "Mask Opacity:" }),
$el("input", {
id: "preview-opacity-slider",
type: "range",
min: "0",
max: "1",
step: "0.05",
value: "0.5",
oninput: (e) => {
const value = e.target.value;
canvas.maskTool.setPreviewOpacity(parseFloat(value));
const valueEl = document.getElementById('preview-opacity-value');
if (valueEl)
valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.slider-value", { id: "preview-opacity-value" }, ["50%"])
]),
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
$el("label", { for: "brush-size-slider", textContent: "Size:" }),
$el("input", {
@@ -998,7 +1033,7 @@ async function createCanvasWidget(node, widget, app) {
}
const canvasNodeInstances = new Map();
app.registerExtension({
name: "Comfy.CanvasNode",
name: "Comfy.LayerForgeNode",
init() {
addStylesheet(getUrl('./css/canvas_view.css'));
const originalQueuePrompt = app.queuePrompt;
@@ -1032,7 +1067,7 @@ app.registerExtension({
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "CanvasNode") {
if (nodeType.comfyClass === "LayerForgeNode") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
log.debug("CanvasNode onNodeCreated: Base widget setup.");

View File

@@ -3,11 +3,15 @@ import { createCanvas } from "./utils/CommonUtils.js";
const log = createModuleLogger('Mask_tool');
export class MaskTool {
constructor(canvasInstance, callbacks = {}) {
// Track strokes during drawing for efficient overlay updates
this.currentStrokePoints = [];
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
this.canvasInstance = canvasInstance;
this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null;
// Initialize stroke tracking for overlay drawing
this.currentStrokePoints = [];
// Initialize chunked mask system
this.maskChunks = new Map();
this.chunkSize = 512;
@@ -28,8 +32,9 @@ export class MaskTool {
this.isOverlayVisible = true;
this.isActive = false;
this.brushSize = 20;
this.brushStrength = 0.5;
this.brushHardness = 0.5;
this._brushStrength = 0.5;
this._brushHardness = 0.5;
this._previewOpacity = 0.5; // Default 50% opacity for preview
this.isDrawing = false;
this.lastPosition = null;
const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
@@ -79,8 +84,27 @@ export class MaskTool {
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
}
}
// Getters for brush properties
get brushStrength() {
return this._brushStrength;
}
get brushHardness() {
return this._brushHardness;
}
get previewOpacity() {
return this._previewOpacity;
}
setBrushHardness(hardness) {
this.brushHardness = Math.max(0, Math.min(1, hardness));
this._brushHardness = Math.max(0, Math.min(1, hardness));
}
setPreviewOpacity(opacity) {
this._previewOpacity = Math.max(0, Math.min(1, opacity));
// Update the stroke overlay canvas opacity when preview opacity changes
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
}
// Trigger canvas render to update mask display opacity
this.canvasInstance.render();
}
initMaskCanvas() {
// Initialize chunked system
@@ -671,16 +695,17 @@ export class MaskTool {
this.brushSize = Math.max(1, size);
}
setBrushStrength(strength) {
this.brushStrength = Math.max(0, Math.min(1, strength));
this._brushStrength = Math.max(0, Math.min(1, strength));
}
handleMouseDown(worldCoords, viewCoords) {
if (!this.isActive)
return;
this.isDrawing = true;
this.lastPosition = worldCoords;
// Activate chunks around the drawing position for performance
this.updateActiveChunksForDrawing(worldCoords);
this.draw(worldCoords);
// Initialize stroke tracking for live preview
this.currentStrokePoints = [worldCoords];
// Clear any previous stroke overlay
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.clearPreview();
}
handleMouseMove(worldCoords, viewCoords) {
@@ -689,14 +714,69 @@ export class MaskTool {
}
if (!this.isActive || !this.isDrawing)
return;
// Dynamically update active chunks as user moves while drawing
this.updateActiveChunksForDrawing(worldCoords);
this.draw(worldCoords);
// Add point to stroke tracking
this.currentStrokePoints.push(worldCoords);
// Draw interpolated segments for smooth strokes without gaps
if (this.lastPosition) {
// Calculate distance between last and current position
const dx = worldCoords.x - this.lastPosition.x;
const dy = worldCoords.y - this.lastPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// If distance is small, just draw a single segment
if (distance < this.brushSize / 4) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
}
else {
// Interpolate points for smooth drawing without gaps
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
// Draw all interpolated segments
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(interpolatedPoints[i], interpolatedPoints[i + 1]);
}
}
}
this.lastPosition = worldCoords;
}
/**
* Interpolates points between two positions to create smooth strokes without gaps
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
*/
interpolatePoints(start, end, distance) {
const points = [];
// Calculate number of interpolated points based on brush size
// More points = smoother line
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
const numSteps = Math.ceil(distance / stepSize);
// Always include start point
points.push(start);
// Interpolate intermediate points
for (let i = 1; i < numSteps; i++) {
const t = i / numSteps;
points.push({
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t
});
}
// Always include end point
points.push(end);
return points;
}
/**
* Called when viewport changes during drawing to update stroke overlay
* This ensures the stroke preview scales correctly with zoom changes
*/
handleViewportChange() {
if (this.isDrawing && this.currentStrokePoints.length > 1) {
// Redraw the entire stroke overlay with new viewport settings
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
}
}
handleMouseLeave() {
this.previewVisible = false;
this.clearPreview();
// Clear overlay canvases when mouse leaves
this.canvasInstance.canvasRenderer.clearOverlay();
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
}
handleMouseEnter() {
this.previewVisible = true;
@@ -706,10 +786,15 @@ export class MaskTool {
return;
if (this.isDrawing) {
this.isDrawing = false;
// Commit the stroke from overlay to actual mask chunks
this.commitStrokeToChunks();
// Clear stroke overlay and reset state
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.currentStrokePoints = [];
this.lastPosition = null;
this.currentDrawingChunk = null;
// After drawing is complete, update active canvas to show all chunks
this.updateActiveMaskCanvas(true); // forceShowAll = true
this.updateActiveMaskCanvas(true); // Force full update
this.completeMaskOperation();
this.drawBrushPreview(viewCoords);
}
@@ -724,6 +809,38 @@ export class MaskTool {
// This prevents unnecessary recomposition during drawing
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
}
/**
* Commits the current stroke from overlay to actual mask chunks
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
*/
commitStrokeToChunks() {
if (this.currentStrokePoints.length < 2) {
return; // Need at least 2 points for a stroke
}
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
// Replay the entire stroke path with interpolation for smooth, accurate lines
for (let i = 1; i < this.currentStrokePoints.length; i++) {
const startPoint = this.currentStrokePoints[i - 1];
const endPoint = this.currentStrokePoints[i];
// Calculate distance between points
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.brushSize / 4) {
// Small distance - draw single segment
this.drawOnChunks(startPoint, endPoint);
}
else {
// Large distance - interpolate for smooth line without gaps
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
// Draw all interpolated segments
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
}
}
}
log.debug("Stroke committed to chunks successfully with interpolation");
}
/**
* Draws a line between two world coordinates on the appropriate chunks
*/
@@ -767,13 +884,13 @@ export class MaskTool {
chunk.ctx.moveTo(startLocal.x, startLocal.y);
chunk.ctx.lineTo(endLocal.x, endLocal.y);
const gradientRadius = this.brushSize / 2;
if (this.brushHardness === 1) {
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
if (this._brushHardness === 1) {
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
}
else {
const innerRadius = gradientRadius * this.brushHardness;
const innerRadius = gradientRadius * this._brushHardness;
const gradient = chunk.ctx.createRadialGradient(endLocal.x, endLocal.y, innerRadius, endLocal.x, endLocal.y, gradientRadius);
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
gradient.addColorStop(0, `rgba(255, 255, 255, ${this._brushStrength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
chunk.ctx.strokeStyle = gradient;
}
@@ -805,28 +922,17 @@ export class MaskTool {
return true; // For now, always draw - more precise intersection can be added later
}
/**
* Updates active canvas when drawing affects chunks with throttling to prevent lag
* During drawing, only updates the affected active chunks for performance
* Updates active canvas when drawing affects chunks
* Since we now use overlay during drawing, this is only called after drawing is complete
*/
updateActiveCanvasIfNeeded(startWorld, endWorld) {
// Calculate which chunks were affected by this drawing operation
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
// During drawing, only update affected chunks that are active for performance
if (this.isDrawing) {
// Use throttled partial update for active chunks only
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
}
else {
// This method is now simplified - we only update after drawing is complete
// The overlay handles all live preview, so we don't need complex chunk activation
if (!this.isDrawing) {
// Not drawing - do full update to show all chunks
this.updateActiveMaskCanvas(true);
}
// During drawing, we don't update chunks at all - overlay handles preview
}
/**
* Schedules a throttled update of the active mask canvas to prevent excessive redraws
@@ -903,18 +1009,12 @@ export class MaskTool {
}
drawBrushPreview(viewCoords) {
if (!this.previewVisible || this.isDrawing) {
this.clearPreview();
this.canvasInstance.canvasRenderer.clearOverlay();
return;
}
this.clearPreview();
const zoom = this.canvasInstance.viewport.zoom;
const radius = (this.brushSize / 2) * zoom;
this.previewCtx.beginPath();
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
this.previewCtx.lineWidth = 1;
this.previewCtx.setLineDash([2, 4]);
this.previewCtx.stroke();
// Use overlay canvas instead of preview canvas for brush cursor
const worldCoords = this.canvasInstance.lastMousePosition;
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
}
clearPreview() {
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);

View File

@@ -6,6 +6,7 @@ import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.j
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
import { convertToImage } from "./utils/ImageUtils.js";
import { updateNodePreview } from "./utils/PreviewUtils.js";
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
const log = createModuleLogger('SAMDetectorIntegration');
/**
* SAM Detector Integration for LayerForge
@@ -324,6 +325,8 @@ async function handleSAMDetectorResult(node, resultImage) {
node.samOriginalImgSrc = null;
}
}
// Store original onClipspaceEditorSave function to restore later
let originalOnClipspaceEditorSave = null;
// Function to setup SAM Detector hook in menu options
export function setupSAMDetectorHook(node, options) {
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
@@ -347,8 +350,39 @@ export function setupSAMDetectorHook(node, options) {
// Set the image to the node for clipspace
node.imgs = [uploadResult.imageElement];
node.clipspaceImg = uploadResult.imageElement;
// Ensure proper clipspace structure for updated ComfyUI
if (!ComfyApp.clipspace) {
ComfyApp.clipspace = {};
}
// Set up clipspace with proper indices
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
ComfyApp.clipspace.selectedIndex = 0;
ComfyApp.clipspace.combinedIndex = 0;
ComfyApp.clipspace.img_paste_mode = 'selected';
// Copy to ComfyUI clipspace
ComfyApp.copyToClipspace(node);
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
if (!originalOnClipspaceEditorSave) {
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
ComfyApp.onClipspaceEditorSave = function () {
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
// Use the unified clipspace validation function
const isValid = validateAndFixClipspace();
if (!isValid) {
log.error("Clipspace validation failed, cannot proceed with paste");
return;
}
// Call the original function
if (originalOnClipspaceEditorSave) {
originalOnClipspaceEditorSave.call(ComfyApp);
}
// Restore the original function after use
if (originalOnClipspaceEditorSave) {
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
originalOnClipspaceEditorSave = null;
}
};
}
// Start monitoring for SAM Detector results
startSAMDetectorMonitoring(node);
log.info("Canvas automatically sent to clipspace and monitoring started");

170
js/css/blend_mode_menu.css Normal file
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-weight: 550;
cursor: pointer;
transition: all 0.2s ease-in-out;
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
min-width: 80px;
text-align: center;
margin: 2px;
@@ -213,7 +213,7 @@
border-radius: 5px;
border: 1px solid #555;
cursor: pointer;
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
user-select: none;
padding: 0;
font-family: inherit;
@@ -346,6 +346,11 @@
background-color: #4a4a4a !important;
}
.clipboard-switch.disabled .switch-labels {
color: #777 !important;
}
.painter-separator {
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

@@ -1,10 +1,9 @@
import { createModuleLogger } from "./LoggerUtils.js";
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
import { safeClipspacePaste } from "./ClipspaceUtils.js";
// @ts-ignore
import { api } from "../../../scripts/api.js";
// @ts-ignore
import { ComfyApp } from "../../../scripts/app.js";
const log = createModuleLogger('ClipboardManager');
export class ClipboardManager {
constructor(canvas) {
@@ -39,7 +38,12 @@ export class ClipboardManager {
*/
this.tryClipspacePaste = withErrorHandling(async (addMode) => {
log.info("Attempting to paste from ComfyUI Clipspace");
ComfyApp.pasteFromClipspace(this.canvas.node);
// Use the unified clipspace validation and paste function
const pasteSuccess = safeClipspacePaste(this.canvas.node);
if (!pasteSuccess) {
log.debug("Safe clipspace paste failed");
return false;
}
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
const clipspaceImage = this.canvas.node.imgs[0];
if (clipspaceImage && clipspaceImage.src) {

View File

@@ -0,0 +1,99 @@
import { createModuleLogger } from "./LoggerUtils.js";
// @ts-ignore
import { ComfyApp } from "../../../scripts/app.js";
const log = createModuleLogger('ClipspaceUtils');
/**
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
*/
export function validateAndFixClipspace() {
log.debug("Validating and fixing clipspace structure");
// Check if clipspace exists
if (!ComfyApp.clipspace) {
log.debug("ComfyUI clipspace is not available");
return false;
}
// Validate clipspace structure
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
log.debug("ComfyUI clipspace has no images");
return false;
}
log.debug("Current clipspace state:", {
hasImgs: !!ComfyApp.clipspace.imgs,
imgsLength: ComfyApp.clipspace.imgs?.length,
selectedIndex: ComfyApp.clipspace.selectedIndex,
combinedIndex: ComfyApp.clipspace.combinedIndex,
img_paste_mode: ComfyApp.clipspace.img_paste_mode
});
// Ensure required indices are set
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
ComfyApp.clipspace.selectedIndex = 0;
log.debug("Fixed clipspace selectedIndex to 0");
}
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
ComfyApp.clipspace.combinedIndex = 0;
log.debug("Fixed clipspace combinedIndex to 0");
}
if (!ComfyApp.clipspace.img_paste_mode) {
ComfyApp.clipspace.img_paste_mode = 'selected';
log.debug("Fixed clipspace img_paste_mode to 'selected'");
}
// Ensure indices are within bounds
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
ComfyApp.clipspace.selectedIndex = maxIndex;
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
}
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
ComfyApp.clipspace.combinedIndex = maxIndex;
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
}
// Verify the image at combinedIndex exists and has src
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
if (!combinedImg || !combinedImg.src) {
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
// Try to use the first available image
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
ComfyApp.clipspace.combinedIndex = i;
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
break;
}
}
// Final check - if still no valid image found
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
if (!finalImg || !finalImg.src) {
log.error("No valid images found in clipspace after attempting fixes");
return false;
}
}
log.debug("Final clipspace structure:", {
selectedIndex: ComfyApp.clipspace.selectedIndex,
combinedIndex: ComfyApp.clipspace.combinedIndex,
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
imgsLength: ComfyApp.clipspace.imgs?.length,
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
});
return true;
}
/**
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
* @param {any} node - The ComfyUI node to paste to
* @returns {boolean} - True if paste was successful, false otherwise
*/
export function safeClipspacePaste(node) {
log.debug("Attempting safe clipspace paste");
if (!validateAndFixClipspace()) {
log.debug("Clipspace validation failed, cannot paste");
return false;
}
try {
ComfyApp.pasteFromClipspace(node);
log.debug("Successfully called pasteFromClipspace");
return true;
}
catch (error) {
log.error("Error calling pasteFromClipspace:", error);
return false;
}
}

View File

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

View File

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

View File

@@ -84,6 +84,8 @@ export class Canvas {
node: ComfyNode;
offscreenCanvas: HTMLCanvasElement;
offscreenCtx: CanvasRenderingContext2D | null;
overlayCanvas: HTMLCanvasElement;
overlayCtx: CanvasRenderingContext2D;
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
onViewportChange: (() => void) | null;
onStateChange: (() => void) | undefined;
@@ -122,6 +124,16 @@ export class Canvas {
});
this.offscreenCanvas = offscreenCanvas;
this.offscreenCtx = offscreenCtx;
// Create overlay canvas for brush cursor and other lightweight overlays
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
alpha: true,
willReadFrequently: false
});
if (!overlayCtx) throw new Error("Could not create overlay canvas context");
this.overlayCanvas = overlayCanvas;
this.overlayCtx = overlayCtx;
this.canvasContainer = null;
this.dataInitialized = false;

View File

@@ -269,7 +269,12 @@ export class CanvasIO {
log.error(`Failed to send data for node ${nodeId}:`, error);
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
throw new Error(
`Failed to get confirmation from server for node ${nodeId}. ` +
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`
);
}
}

View File

@@ -10,15 +10,36 @@ interface MouseCoordinates {
view: Point;
}
interface ModifierState {
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
interface TransformOrigin {
x: number;
y: number;
width: number;
height: number;
rotation: number;
centerX: number;
centerY: number;
originalWidth?: number;
originalHeight?: number;
cropBounds?: { x: number; y: number; width: number; height: number };
}
interface InteractionState {
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape';
panStart: Point;
dragStart: Point;
transformOrigin: Partial<Layer> & { centerX?: number, centerY?: number };
transformOrigin: TransformOrigin | null;
resizeHandle: string | null;
resizeAnchor: Point;
canvasResizeStart: Point;
isCtrlPressed: boolean;
isMetaPressed: boolean;
isAltPressed: boolean;
isShiftPressed: boolean;
isSPressed: boolean;
@@ -35,17 +56,35 @@ export class CanvasInteractions {
public interaction: InteractionState;
private originalLayerPositions: Map<Layer, Point>;
// Bound event handlers to enable proper removeEventListener and avoid leaks
private onMouseDown = (e: MouseEvent) => this.handleMouseDown(e);
private onMouseMove = (e: MouseEvent) => this.handleMouseMove(e);
private onMouseUp = (e: MouseEvent) => this.handleMouseUp(e);
private onMouseEnter = (e: MouseEvent) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); };
private onMouseLeave = (e: MouseEvent) => { this.canvas.isMouseOver = false; this.handleMouseLeave(e); };
private onWheel = (e: WheelEvent) => this.handleWheel(e);
private onKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e);
private onKeyUp = (e: KeyboardEvent) => this.handleKeyUp(e);
private onDragOver = (e: DragEvent) => this.handleDragOver(e);
private onDragEnter = (e: DragEvent) => this.handleDragEnter(e);
private onDragLeave = (e: DragEvent) => this.handleDragLeave(e);
private onDrop = (e: DragEvent) => { this.handleDrop(e); };
private onContextMenu = (e: MouseEvent) => this.handleContextMenu(e);
private onBlur = () => this.handleBlur();
private onPaste = (e: ClipboardEvent) => this.handlePasteEvent(e);
constructor(canvas: Canvas) {
this.canvas = canvas;
this.interaction = {
mode: 'none',
panStart: { x: 0, y: 0 },
dragStart: { x: 0, y: 0 },
transformOrigin: {},
transformOrigin: null,
resizeHandle: null,
resizeAnchor: { x: 0, y: 0 },
canvasResizeStart: { x: 0, y: 0 },
isCtrlPressed: false,
isMetaPressed: false,
isAltPressed: false,
isShiftPressed: false,
isSPressed: false,
@@ -68,13 +107,21 @@ export class CanvasInteractions {
};
}
private getModifierState(e?: MouseEvent | WheelEvent | KeyboardEvent): ModifierState {
return {
ctrl: this.interaction.isCtrlPressed || (e as any)?.ctrlKey || false,
shift: this.interaction.isShiftPressed || (e as any)?.shiftKey || false,
alt: this.interaction.isAltPressed || (e as any)?.altKey || false,
meta: this.interaction.isMetaPressed || (e as any)?.metaKey || false,
};
}
private preventEventDefaults(e: Event): void {
e.preventDefault();
e.stopPropagation();
}
private performZoomOperation(worldCoords: Point, zoomFactor: number): void {
const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
@@ -84,6 +131,11 @@ export class CanvasInteractions {
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
// Update stroke overlay if mask tool is drawing during zoom
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.onViewportChange?.();
}
@@ -106,34 +158,49 @@ export class CanvasInteractions {
}
setupEventListeners(): void {
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this) as EventListener);
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this) as EventListener);
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this) as EventListener);
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this) as EventListener);
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, { passive: false });
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown as EventListener);
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove as EventListener);
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp as EventListener);
this.canvas.canvas.addEventListener('wheel', this.onWheel as EventListener, { passive: false });
this.canvas.canvas.addEventListener('keydown', this.onKeyDown as EventListener);
this.canvas.canvas.addEventListener('keyup', this.onKeyUp as EventListener);
// Add a blur event listener to the window to reset key states
window.addEventListener('blur', this.handleBlur.bind(this));
window.addEventListener('blur', this.onBlur);
document.addEventListener('paste', this.handlePasteEvent.bind(this));
document.addEventListener('paste', this.onPaste as unknown as EventListener);
this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => {
this.canvas.isMouseOver = true;
this.handleMouseEnter(e);
});
this.canvas.canvas.addEventListener('mouseleave', (e: MouseEvent) => {
this.canvas.isMouseOver = false;
this.handleMouseLeave(e);
});
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this) as EventListener);
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this) as EventListener);
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this) as EventListener);
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this) as unknown as EventListener);
this.canvas.canvas.addEventListener('dragover', this.onDragOver as EventListener);
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter as EventListener);
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave as EventListener);
this.canvas.canvas.addEventListener('drop', this.onDrop as unknown as EventListener);
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this) as EventListener);
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu as EventListener);
}
teardownEventListeners(): void {
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown as EventListener);
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove as EventListener);
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp as EventListener);
this.canvas.canvas.removeEventListener('wheel', this.onWheel as EventListener);
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener);
window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste as unknown as EventListener);
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter as EventListener);
this.canvas.canvas.removeEventListener('mouseleave', this.onMouseLeave as EventListener);
this.canvas.canvas.removeEventListener('dragover', this.onDragOver as EventListener);
this.canvas.canvas.removeEventListener('dragenter', this.onDragEnter as EventListener);
this.canvas.canvas.removeEventListener('dragleave', this.onDragLeave as EventListener);
this.canvas.canvas.removeEventListener('drop', this.onDrop as unknown as EventListener);
this.canvas.canvas.removeEventListener('contextmenu', this.onContextMenu as EventListener);
}
/**
@@ -177,10 +244,11 @@ export class CanvasInteractions {
handleMouseDown(e: MouseEvent): void {
this.canvas.canvas.focus();
const coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
this.canvas.render();
// Don't render here - mask tool will handle its own drawing
return;
}
@@ -192,11 +260,11 @@ export class CanvasInteractions {
// --- Ostateczna, poprawna kolejność sprawdzania ---
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
if (e.shiftKey && e.ctrlKey) {
if (mods.shift && mods.ctrl) {
this.startCanvasMove(coords.world);
return;
}
if (e.shiftKey) {
if (mods.shift) {
// Clear custom shape when starting canvas resize
if (this.canvas.outputAreaShape) {
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
@@ -222,7 +290,7 @@ export class CanvasInteractions {
}
return;
}
if (e.button !== 0) { // Środkowy przycisk
if (e.button === 1) { // Środkowy przycisk
this.startPanning(e);
return;
}
@@ -241,7 +309,7 @@ export class CanvasInteractions {
}
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
this.startPanningOrClearSelection(e);
this.startPanning(e, true); // clearSelection = true
}
handleMouseMove(e: MouseEvent): void {
@@ -264,7 +332,7 @@ export class CanvasInteractions {
switch (this.interaction.mode) {
case 'drawingMask':
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
this.canvas.render();
// Don't render during mask drawing - it's handled by mask tool internally
break;
case 'panning':
this.panViewport(e);
@@ -286,6 +354,10 @@ export class CanvasInteractions {
break;
default:
this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) {
this.canvas.canvasRenderer.drawMaskBrushCursor(coords.world);
}
break;
}
@@ -300,6 +372,7 @@ export class CanvasInteractions {
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(coords.view);
// Render only once after drawing is complete
this.canvas.render();
return;
}
@@ -316,6 +389,16 @@ export class CanvasInteractions {
this.logDragCompletion(coords);
}
// Handle end of crop bounds transformation before resetting interaction state
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
}
// Handle end of scale transformation (normal transform mode) before resetting interaction state
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
}
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag;
@@ -387,8 +470,17 @@ export class CanvasInteractions {
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
this.performZoomOperation(coords.world, zoomFactor);
} else {
// Layer transformation when layers are selected
this.handleLayerWheelTransformation(e);
// Check if mouse is over any selected layer
const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y);
if (isOverSelectedLayer) {
// Layer transformation when layers are selected and mouse is over selected layer
this.handleLayerWheelTransformation(e);
} else {
// Zoom operation when mouse is not over selected layers
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
this.performZoomOperation(coords.world, zoomFactor);
}
}
this.canvas.render();
@@ -398,14 +490,15 @@ export class CanvasInteractions {
}
private handleLayerWheelTransformation(e: WheelEvent): void {
const mods = this.getModifierState(e);
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
const direction = e.deltaY < 0 ? 1 : -1;
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
if (e.shiftKey) {
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
if (mods.shift) {
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
} else {
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
this.handleLayerScaling(layer, mods.ctrl, e.deltaY);
}
});
}
@@ -445,11 +538,14 @@ export class CanvasInteractions {
layer.height *= scaleFactor;
layer.x += (oldWidth - layer.width) / 2;
layer.y += (oldHeight - layer.height) / 2;
// Handle wheel scaling end for layers with blend area
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
}
}
private calculateGridBasedScaling(oldHeight: number, deltaY: number): number {
const gridSize = 64;
const gridSize = 64; // Grid size - could be made configurable in the future
const direction = deltaY > 0 ? -1 : 1;
let targetHeight;
@@ -474,6 +570,7 @@ export class CanvasInteractions {
handleKeyDown(e: KeyboardEvent): void {
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Meta') this.interaction.isMetaPressed = true;
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
@@ -492,11 +589,12 @@ export class CanvasInteractions {
}
// Globalne skróty (Undo/Redo/Copy/Paste)
if (e.ctrlKey || e.metaKey) {
const mods = this.getModifierState(e);
if (mods.ctrl || mods.meta) {
let handled = true;
switch (e.key.toLowerCase()) {
case 'z':
if (e.shiftKey) {
if (mods.shift) {
this.canvas.redo();
} else {
this.canvas.undo();
@@ -523,7 +621,7 @@ export class CanvasInteractions {
// Skróty kontekstowe (zależne od zaznaczenia)
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const step = e.shiftKey ? 10 : 1;
const step = mods.shift ? 10 : 1;
let needsRender = false;
// Używamy e.code dla spójności i niezależności od układu klawiatury
@@ -558,6 +656,7 @@ export class CanvasInteractions {
handleKeyUp(e: KeyboardEvent): void {
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
if (e.key === 'Meta') this.interaction.isMetaPressed = false;
if (e.key === 'Shift') this.interaction.isShiftPressed = false;
if (e.key === 'Alt') this.interaction.isAltPressed = false;
if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false;
@@ -577,6 +676,7 @@ export class CanvasInteractions {
handleBlur(): void {
log.debug('Window lost focus, resetting key states.');
this.interaction.isCtrlPressed = false;
this.interaction.isMetaPressed = false;
this.interaction.isAltPressed = false;
this.interaction.isShiftPressed = false;
this.interaction.isSPressed = false;
@@ -602,6 +702,12 @@ export class CanvasInteractions {
}
updateCursor(worldCoords: Point): void {
// If actively rotating, show grabbing cursor
if (this.interaction.mode === 'rotating') {
this.canvas.canvas.style.cursor = 'grabbing';
return;
}
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) {
@@ -650,7 +756,9 @@ export class CanvasInteractions {
prepareForDrag(layer: Layer, worldCoords: Point): void {
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
if (this.interaction.isCtrlPressed) {
// Support both Ctrl (Windows/Linux) and Cmd (macOS) for multi-selection
const mods = this.getModifierState();
if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) {
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
@@ -668,14 +776,13 @@ export class CanvasInteractions {
this.interaction.dragStart = {...worldCoords};
}
startPanningOrClearSelection(e: MouseEvent): void {
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
if (!this.interaction.isCtrlPressed) {
startPanning(e: MouseEvent, clearSelection: boolean = true): void {
// Unified panning method - can optionally clear selection
if (clearSelection && !this.interaction.isCtrlPressed) {
this.canvas.canvasSelection.updateSelection([]);
}
this.interaction.mode = 'panning';
this.interaction.panStart = {x: e.clientX, y: e.clientY};
this.interaction.panStart = { x: e.clientX, y: e.clientY };
}
startCanvasResize(worldCoords: Point): void {
@@ -730,20 +837,18 @@ export class CanvasInteractions {
this.canvas.saveState();
}
startPanning(e: MouseEvent): void {
if (!this.interaction.isCtrlPressed) {
this.canvas.canvasSelection.updateSelection([]);
}
this.interaction.mode = 'panning';
this.interaction.panStart = { x: e.clientX, y: e.clientY };
}
panViewport(e: MouseEvent): void {
const dx = e.clientX - this.interaction.panStart.x;
const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = {x: e.clientX, y: e.clientY};
// Update stroke overlay if mask tool is drawing during pan
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.render();
this.canvas.onViewportChange?.();
}
@@ -805,7 +910,7 @@ export class CanvasInteractions {
}
const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
if (!o) return;
const handle = this.interaction.resizeHandle;
const anchor = this.interaction.resizeAnchor;
@@ -844,8 +949,15 @@ export class CanvasInteractions {
// Rotate mouse delta into the layer's unrotated frame
const deltaX_world = mouseX_local - dragStartX_local;
const deltaY_world = mouseY_local - dragStartY_local;
const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
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;
@@ -857,22 +969,37 @@ export class CanvasInteractions {
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')) {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
if (isFlippedH) newCropBounds.width += delta_image_x;
else {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
}
}
if (handle?.includes('e')) {
newCropBounds.width += delta_image_x;
if (isFlippedH) {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
} else newCropBounds.width += delta_image_x;
}
if (handle?.includes('n')) {
newCropBounds.y += delta_image_y;
newCropBounds.height -= delta_image_y;
if (isFlippedV) newCropBounds.height += delta_image_y;
else {
newCropBounds.y += delta_image_y;
newCropBounds.height -= delta_image_y;
}
}
if (handle?.includes('s')) {
newCropBounds.height += delta_image_y;
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
// 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;
@@ -939,7 +1066,7 @@ export class CanvasInteractions {
if (!layer) return;
const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined) return;
if (!o) return;
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { createCanvas } from "./utils/CommonUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
import type { Canvas } from './Canvas';
import type { Layer } from './types';
@@ -33,6 +34,9 @@ export class CanvasLayersPanel {
// Preload icons
this.initializeIcons();
// Load CSS for layers panel
addStylesheet(getUrl('./css/layers_panel.css'));
log.info('CanvasLayersPanel initialized');
}
@@ -47,23 +51,16 @@ export class CanvasLayersPanel {
private createIconElement(toolName: string, size: number = 16): HTMLElement {
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `
width: ${size}px;
height: ${size}px;
display: flex;
align-items: center;
justify-content: center;
`;
iconContainer.className = 'icon-container';
iconContainer.style.width = `${size}px`;
iconContainer.style.height = `${size}px`;
const icon = iconLoader.getIcon(toolName);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement;
img.style.cssText = `
width: ${size}px;
height: ${size}px;
filter: brightness(0) invert(1);
`;
img.style.width = `${size}px`;
img.style.height = `${size}px`;
iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) {
const { canvas, ctx } = createCanvas(size, size);
@@ -74,9 +71,9 @@ export class CanvasLayersPanel {
}
} else {
// Fallback text
iconContainer.classList.add('fallback-text');
iconContainer.textContent = toolName.charAt(0).toUpperCase();
iconContainer.style.fontSize = `${size * 0.6}px`;
iconContainer.style.color = '#ffffff';
}
return iconContainer;
@@ -88,25 +85,16 @@ export class CanvasLayersPanel {
} else {
// Create a "hidden" version of the visibility icon
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
`;
iconContainer.className = 'icon-container visibility-hidden';
iconContainer.style.width = '16px';
iconContainer.style.height = '16px';
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement;
img.style.cssText = `
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
opacity: 0.3;
`;
img.style.width = '16px';
img.style.height = '16px';
iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) {
const { canvas, ctx } = createCanvas(16, 16);
@@ -118,9 +106,9 @@ export class CanvasLayersPanel {
}
} else {
// Fallback
iconContainer.classList.add('fallback-text');
iconContainer.textContent = 'H';
iconContainer.style.fontSize = '10px';
iconContainer.style.color = '#888888';
}
return iconContainer;
@@ -144,8 +132,6 @@ export class CanvasLayersPanel {
`;
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
this.injectStyles();
// Setup event listeners dla przycisków
this.setupControlButtons();
@@ -163,218 +149,10 @@ export class CanvasLayersPanel {
return this.container;
}
injectStyles(): void {
const styleId = 'layers-panel-styles';
if (document.getElementById(styleId)) {
return; // Style już istnieją
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.layers-panel {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 8px;
height: 100%;
overflow: hidden;
font-family: Arial, sans-serif;
font-size: 12px;
color: #ffffff;
user-select: none;
display: flex;
flex-direction: column;
}
.layers-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #3a3a3a;
margin-bottom: 8px;
}
.layers-panel-title {
font-weight: bold;
color: #ffffff;
}
.layers-panel-controls {
display: flex;
gap: 4px;
}
.layers-btn {
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ffffff;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.layers-btn:hover {
background: #4a4a4a;
}
.layers-btn:active {
background: #5a5a5a;
}
.layers-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.layer-row {
display: flex;
align-items: center;
padding: 6px 4px;
margin-bottom: 2px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s ease;
position: relative;
gap: 6px;
}
.layer-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.layer-row.selected {
background: #2d5aa0 !important;
box-shadow: inset 0 0 0 1px #4a7bc8;
}
.layer-row.dragging {
opacity: 0.6;
}
.layer-thumbnail {
width: 48px;
height: 48px;
border: 1px solid #4a4a4a;
border-radius: 2px;
background: transparent;
position: relative;
flex-shrink: 0;
overflow: hidden;
}
.layer-thumbnail canvas {
width: 100%;
height: 100%;
display: block;
}
.layer-thumbnail::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
z-index: 1;
}
.layer-thumbnail canvas {
position: relative;
z-index: 2;
}
.layer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 4px;
border-radius: 2px;
color: #ffffff;
}
.layer-name.editing {
background: #4a4a4a;
border: 1px solid #6a6a6a;
outline: none;
color: #ffffff;
}
.layer-name input {
background: transparent;
border: none;
color: #ffffff;
font-size: 12px;
width: 100%;
outline: none;
}
.drag-insertion-line {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #4a7bc8;
border-radius: 1px;
z-index: 1000;
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
}
.layers-container::-webkit-scrollbar {
width: 6px;
}
.layers-container::-webkit-scrollbar-track {
background: #2a2a2a;
}
.layers-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
.layer-visibility-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 2px;
font-size: 14px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.layer-visibility-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
`;
document.head.appendChild(style);
log.debug('Styles injected');
}
setupControlButtons(): void {
if (!this.container) return;
const deleteBtn = this.container.querySelector('#delete-layer-btn');
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
// Add delete icon to button
if (deleteBtn) {
@@ -386,6 +164,9 @@ export class CanvasLayersPanel {
log.info('Delete layer button clicked');
this.deleteSelectedLayers();
});
// Initial button state update
this.updateButtonStates();
}
renderLayers(): void {
@@ -495,6 +276,7 @@ export class CanvasLayersPanel {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.updateSelection(newSelection);
this.updateSelectionAppearance();
this.updateButtonStates();
}
});
@@ -532,7 +314,8 @@ export class CanvasLayersPanel {
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance();
this.updateSelectionAppearance();
this.updateButtonStates();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
}
@@ -751,12 +534,32 @@ export class CanvasLayersPanel {
});
}
/**
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
*/
updateButtonStates(): void {
if (!this.container) return;
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
if (deleteBtn) {
deleteBtn.disabled = !hasSelectedLayers;
deleteBtn.title = hasSelectedLayers
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
: 'No layers selected';
}
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
}
/**
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
*/
onSelectionChanged(): void {
this.updateSelectionAppearance();
this.updateButtonStates();
}
destroy(): void {

View File

@@ -8,12 +8,19 @@ export class CanvasRenderer {
lastRenderTime: any;
renderAnimationFrame: any;
renderInterval: any;
// Overlay used to preview in-progress mask strokes (separate from cursor overlay)
strokeOverlayCanvas!: HTMLCanvasElement;
strokeOverlayCtx!: CanvasRenderingContext2D;
constructor(canvas: any) {
this.canvas = canvas;
this.renderAnimationFrame = null;
this.lastRenderTime = 0;
this.renderInterval = 1000 / 60;
this.isDirty = false;
// Initialize overlay canvases
this.initOverlay();
this.initStrokeOverlay();
}
/**
@@ -141,9 +148,11 @@ export class CanvasRenderer {
ctx.save();
if (this.canvas.maskTool.isActive) {
// In draw mask mode, use the previewOpacity value from the slider
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.5;
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
} else {
// When not in draw mask mode, show mask at full opacity
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0;
}
@@ -205,6 +214,12 @@ export class CanvasRenderer {
}
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
// Ensure overlay canvases are in DOM and properly sized
this.addOverlayToDOM();
this.updateOverlaySize();
this.addStrokeOverlayToDOM();
this.updateStrokeOverlaySize();
// Update Batch Preview UI positions
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager: any) => {
@@ -575,8 +590,10 @@ export class CanvasRenderer {
// Draw line to rotation handle
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(0, -halfH);
ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom);
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();
}
@@ -586,21 +603,33 @@ export class CanvasRenderer {
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
for (const key in handles) {
// Skip rotation handle in crop mode
if (layer.cropMode && key === 'rot') continue;
const point = handles[key];
// The handle position is already in world space, we need it in the layer's rotated space
const localX = point.x - (layer.x + layer.width / 2);
const localY = point.y - (layer.y + layer.height / 2);
// The handle position is already in world space.
// We need to convert it to the layer's local, un-rotated space.
const dx = point.x - centerX;
const dy = point.y - centerY;
// "Un-rotate" the position to get it in the layer's local, un-rotated space
const rad = -layer.rotation * Math.PI / 180;
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// 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(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
@@ -696,4 +725,290 @@ export class CanvasRenderer {
padding: 8
});
}
/**
* Initialize overlay canvas for lightweight overlays like brush cursor
*/
initOverlay(): void {
// Setup overlay canvas to match main canvas
this.updateOverlaySize();
// Position overlay canvas on top of main canvas
this.canvas.overlayCanvas.style.position = 'absolute';
this.canvas.overlayCanvas.style.left = '0px';
this.canvas.overlayCanvas.style.top = '0px';
this.canvas.overlayCanvas.style.pointerEvents = 'none';
this.canvas.overlayCanvas.style.zIndex = '20'; // Above other overlays
// Add overlay to DOM when main canvas is added
this.addOverlayToDOM();
log.debug('Overlay canvas initialized');
}
/**
* Add overlay canvas to DOM if main canvas has a parent
*/
addOverlayToDOM(): void {
if (this.canvas.canvas.parentElement && !this.canvas.overlayCanvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.canvas.overlayCanvas);
log.debug('Overlay canvas added to DOM');
}
}
/**
* Update overlay canvas size to match main canvas
*/
updateOverlaySize(): void {
if (this.canvas.overlayCanvas.width !== this.canvas.canvas.clientWidth ||
this.canvas.overlayCanvas.height !== this.canvas.canvas.clientHeight) {
this.canvas.overlayCanvas.width = Math.max(1, this.canvas.canvas.clientWidth);
this.canvas.overlayCanvas.height = Math.max(1, this.canvas.canvas.clientHeight);
log.debug(`Overlay canvas resized to ${this.canvas.overlayCanvas.width}x${this.canvas.overlayCanvas.height}`);
}
}
/**
* Clear overlay canvas
*/
clearOverlay(): void {
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
}
/**
* Initialize a dedicated overlay for real-time mask stroke preview
*/
initStrokeOverlay(): void {
// Create canvas if not created yet
if (!this.strokeOverlayCanvas) {
this.strokeOverlayCanvas = document.createElement('canvas');
const ctx = this.strokeOverlayCanvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2D context for stroke overlay canvas');
}
this.strokeOverlayCtx = ctx;
}
// Size match main canvas
this.updateStrokeOverlaySize();
// Position above main canvas but below cursor overlay
this.strokeOverlayCanvas.style.position = 'absolute';
this.strokeOverlayCanvas.style.left = '1px';
this.strokeOverlayCanvas.style.top = '1px';
this.strokeOverlayCanvas.style.pointerEvents = 'none';
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
// Opacity is now controlled by MaskTool.previewOpacity
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
// Add to DOM
this.addStrokeOverlayToDOM();
log.debug('Stroke overlay canvas initialized');
}
/**
* Add stroke overlay canvas to DOM if needed
*/
addStrokeOverlayToDOM(): void {
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
log.debug('Stroke overlay canvas added to DOM');
}
}
/**
* Ensure stroke overlay size matches main canvas
*/
updateStrokeOverlaySize(): void {
const w = Math.max(1, this.canvas.canvas.clientWidth);
const h = Math.max(1, this.canvas.canvas.clientHeight);
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
this.strokeOverlayCanvas.width = w;
this.strokeOverlayCanvas.height = h;
log.debug(`Stroke overlay resized to ${w}x${h}`);
}
}
/**
* Clear the stroke overlay
*/
clearMaskStrokeOverlay(): void {
if (!this.strokeOverlayCtx) return;
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
}
/**
* Draw a preview stroke segment onto the stroke overlay in screen space
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
*/
drawMaskStrokeSegment(startWorld: { x: number; y: number }, endWorld: { x: number; y: number }): void {
// Ensure overlay is present and sized
this.updateStrokeOverlaySize();
const zoom = this.canvas.viewport.zoom;
const toScreen = (p: { x: number; y: number }) => ({
x: (p.x - this.canvas.viewport.x) * zoom,
y: (p.y - this.canvas.viewport.y) * zoom
});
const startScreen = toScreen(startWorld);
const endScreen = toScreen(endWorld);
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
const hardness = this.canvas.maskTool.brushHardness;
const strength = this.canvas.maskTool.brushStrength;
// If strength is 0, don't draw anything
if (strength <= 0) {
return;
}
this.strokeOverlayCtx.save();
// Draw line segment exactly as MaskTool does
this.strokeOverlayCtx.beginPath();
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
// Match the gradient setup from MaskTool's drawLineOnChunk
if (hardness === 1) {
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
} else {
const innerRadius = brushRadius * hardness;
const gradient = this.strokeOverlayCtx.createRadialGradient(
endScreen.x, endScreen.y, innerRadius,
endScreen.x, endScreen.y, brushRadius
);
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
this.strokeOverlayCtx.strokeStyle = gradient;
}
// Match line properties from MaskTool
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
this.strokeOverlayCtx.lineCap = 'round';
this.strokeOverlayCtx.lineJoin = 'round';
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
this.strokeOverlayCtx.stroke();
this.strokeOverlayCtx.restore();
}
/**
* Redraws the entire stroke overlay from world coordinates
* Used when viewport changes during drawing to maintain visual consistency
*/
redrawMaskStrokeOverlay(strokePoints: { x: number; y: number }[]): void {
if (strokePoints.length < 2) return;
// Clear the overlay first
this.clearMaskStrokeOverlay();
// Redraw all segments with current viewport
for (let i = 1; i < strokePoints.length; i++) {
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
}
}
/**
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
* @param worldPoint World coordinates of cursor
*/
drawMaskBrushCursor(worldPoint: { x: number, y: number }): void {
if (!this.canvas.maskTool.isActive || !this.canvas.isMouseOver) {
this.clearOverlay();
return;
}
// Update overlay size if needed
this.updateOverlaySize();
// Clear previous cursor
this.clearOverlay();
// Convert world coordinates to screen coordinates
const screenX = (worldPoint.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (worldPoint.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
// Get brush properties
const brushRadius = (this.canvas.maskTool.brushSize / 2) * this.canvas.viewport.zoom;
const brushStrength = this.canvas.maskTool.brushStrength;
const brushHardness = this.canvas.maskTool.brushHardness;
// Save context state
this.canvas.overlayCtx.save();
// If strength is 0, just draw outline
if (brushStrength > 0) {
// Draw inner fill to visualize brush effect - matches actual brush rendering
const gradient = this.canvas.overlayCtx.createRadialGradient(
screenX, screenY, 0,
screenX, screenY, brushRadius
);
// Preview alpha - subtle to not obscure content
const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
if (brushHardness === 1) {
// Hard brush - uniform fill within radius
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
} else {
// Soft brush - gradient fade matching actual brush
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
if (brushHardness > 0) {
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
}
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
}
this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
this.canvas.overlayCtx.fillStyle = gradient;
this.canvas.overlayCtx.fill();
}
// Draw outer circle (SIZE indicator)
this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
// Stroke opacity based on strength (dimmer when strength is 0)
const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
this.canvas.overlayCtx.lineWidth = 1.5;
// Visual feedback for hardness
if (brushHardness > 0.8) {
// Hard brush - solid line
this.canvas.overlayCtx.setLineDash([]);
} else {
// Soft brush - dashed line
const dashLength = 2 + (1 - brushHardness) * 4;
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
}
this.canvas.overlayCtx.stroke();
// Center dot for small brushes
if (brushRadius < 5) {
this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
this.canvas.overlayCtx.fill();
}
// Restore context state
this.canvas.overlayCtx.restore();
}
/**
* Update overlay position when viewport changes
*/
updateOverlayPosition(): void {
// Overlay canvas is positioned absolutely, so it doesn't need repositioning
// Just ensure it's the right size
this.updateOverlaySize();
}
}

View File

@@ -326,9 +326,6 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => {
const newLayer: Omit<Layer, 'image'> & { imageId: string } = { ...layer, imageId: layer.imageId || '' };
delete (newLayer as any).image;
// Remove cache properties that cannot be serialized for the worker
delete (newLayer as any).blendedImageCache;
delete (newLayer as any).blendedImageDirty;
if (layer.image instanceof HTMLImageElement) {
if (layer.imageId) {

View File

@@ -131,7 +131,6 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
}),
$el("button.painter-button.icon-button", {
textContent: "?",
title: "Show shortcuts",
onmouseenter: (e: MouseEvent) => {
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
showTooltip(e.target as HTMLElement, content);
@@ -210,10 +209,24 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
])
]);
// Helper function to get current tooltip content based on switch state
const getCurrentTooltipContent = () => {
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
return checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
};
// Helper function to update tooltip content if it's currently visible
const updateTooltipIfVisible = () => {
// Only update if tooltip is currently visible
if (helpTooltip.style.display === 'block') {
const tooltipContent = getCurrentTooltipContent();
showTooltip(switchEl, tooltipContent);
}
};
// Tooltip logic
switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
const tooltipContent = getCurrentTooltipContent();
showTooltip(switchEl, tooltipContent);
});
switchEl.addEventListener("mouseleave", hideTooltip);
@@ -231,6 +244,9 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
"🗂️",
"📋"
);
// Update tooltip content immediately after state change
updateTooltipIfVisible();
});
// Initial state
@@ -499,6 +515,10 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
canvas.layers[selectedLayerIndex] = newLayer;
canvas.canvasSelection.updateSelection([newLayer]);
// Invalidate processed image cache when layer image changes (matting)
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
canvas.render();
canvas.saveState();
showSuccessNotification("Background removed successfully!");
@@ -534,7 +554,8 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
$el("div.painter-button-group", {id: "mask-controls"}, [
$el("label.clipboard-switch.mask-switch", {
id: `toggle-mask-switch-${node.id}`,
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" },
title: "Toggle mask overlay visibility on canvas (mask still affects output when disabled)"
}, [
$el("input", {
type: "checkbox",
@@ -619,6 +640,24 @@ $el("label.clipboard-switch.mask-switch", {
setTimeout(() => canvas.render(), 0);
}
}),
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "preview-opacity-slider", textContent: "Mask Opacity:"}),
$el("input", {
id: "preview-opacity-slider",
type: "range",
min: "0",
max: "1",
step: "0.05",
value: "0.5",
oninput: (e: Event) => {
const value = (e.target as HTMLInputElement).value;
canvas.maskTool.setPreviewOpacity(parseFloat(value));
const valueEl = document.getElementById('preview-opacity-value');
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.slider-value", {id: "preview-opacity-value"}, ["50%"])
]),
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
$el("input", {
@@ -1144,7 +1183,7 @@ $el("label.clipboard-switch.mask-switch", {
const canvasNodeInstances = new Map<number, CanvasWidget>();
app.registerExtension({
name: "Comfy.CanvasNode",
name: "Comfy.LayerForgeNode",
init() {
addStylesheet(getUrl('./css/canvas_view.css'));
@@ -1183,7 +1222,7 @@ app.registerExtension({
},
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
if (nodeType.comfyClass === "CanvasNode") {
if (nodeType.comfyClass === "LayerForgeNode") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
log.debug("CanvasNode onNodeCreated: Base widget setup.");

View File

@@ -21,9 +21,10 @@ interface MaskChunk {
}
export class MaskTool {
private brushHardness: number;
private brushSize: number;
private brushStrength: number;
private _brushHardness: number;
public brushSize: number;
private _brushStrength: number;
private _previewOpacity: number;
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
public isActive: boolean;
public isDrawing: boolean;
@@ -31,6 +32,9 @@ export class MaskTool {
private lastPosition: Point | null;
private mainCanvas: HTMLCanvasElement;
// Track strokes during drawing for efficient overlay updates
private currentStrokePoints: Point[] = [];
// Chunked mask system
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
private chunkSize: number;
@@ -72,6 +76,9 @@ export class MaskTool {
this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null;
// Initialize stroke tracking for overlay drawing
this.currentStrokePoints = [];
// Initialize chunked mask system
this.maskChunks = new Map();
this.chunkSize = 512;
@@ -96,8 +103,9 @@ export class MaskTool {
this.isOverlayVisible = true;
this.isActive = false;
this.brushSize = 20;
this.brushStrength = 0.5;
this.brushHardness = 0.5;
this._brushStrength = 0.5;
this._brushHardness = 0.5;
this._previewOpacity = 0.5; // Default 50% opacity for preview
this.isDrawing = false;
this.lastPosition = null;
@@ -156,8 +164,31 @@ export class MaskTool {
}
}
// Getters for brush properties
get brushStrength(): number {
return this._brushStrength;
}
get brushHardness(): number {
return this._brushHardness;
}
get previewOpacity(): number {
return this._previewOpacity;
}
setBrushHardness(hardness: number): void {
this.brushHardness = Math.max(0, Math.min(1, hardness));
this._brushHardness = Math.max(0, Math.min(1, hardness));
}
setPreviewOpacity(opacity: number): void {
this._previewOpacity = Math.max(0, Math.min(1, opacity));
// Update the stroke overlay canvas opacity when preview opacity changes
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
}
// Trigger canvas render to update mask display opacity
this.canvasInstance.render();
}
initMaskCanvas(): void {
@@ -867,7 +898,7 @@ export class MaskTool {
}
setBrushStrength(strength: number): void {
this.brushStrength = Math.max(0, Math.min(1, strength));
this._brushStrength = Math.max(0, Math.min(1, strength));
}
handleMouseDown(worldCoords: Point, viewCoords: Point): void {
@@ -875,10 +906,12 @@ export class MaskTool {
this.isDrawing = true;
this.lastPosition = worldCoords;
// Activate chunks around the drawing position for performance
this.updateActiveChunksForDrawing(worldCoords);
// Initialize stroke tracking for live preview
this.currentStrokePoints = [worldCoords];
// Clear any previous stroke overlay
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.draw(worldCoords);
this.clearPreview();
}
@@ -888,16 +921,83 @@ export class MaskTool {
}
if (!this.isActive || !this.isDrawing) return;
// Dynamically update active chunks as user moves while drawing
this.updateActiveChunksForDrawing(worldCoords);
// Add point to stroke tracking
this.currentStrokePoints.push(worldCoords);
// Draw interpolated segments for smooth strokes without gaps
if (this.lastPosition) {
// Calculate distance between last and current position
const dx = worldCoords.x - this.lastPosition.x;
const dy = worldCoords.y - this.lastPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// If distance is small, just draw a single segment
if (distance < this.brushSize / 4) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
} else {
// Interpolate points for smooth drawing without gaps
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
// Draw all interpolated segments
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(
interpolatedPoints[i],
interpolatedPoints[i + 1]
);
}
}
}
this.draw(worldCoords);
this.lastPosition = worldCoords;
}
/**
* Interpolates points between two positions to create smooth strokes without gaps
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
*/
private interpolatePoints(start: Point, end: Point, distance: number): Point[] {
const points: Point[] = [];
// Calculate number of interpolated points based on brush size
// More points = smoother line
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
const numSteps = Math.ceil(distance / stepSize);
// Always include start point
points.push(start);
// Interpolate intermediate points
for (let i = 1; i < numSteps; i++) {
const t = i / numSteps;
points.push({
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t
});
}
// Always include end point
points.push(end);
return points;
}
/**
* Called when viewport changes during drawing to update stroke overlay
* This ensures the stroke preview scales correctly with zoom changes
*/
handleViewportChange(): void {
if (this.isDrawing && this.currentStrokePoints.length > 1) {
// Redraw the entire stroke overlay with new viewport settings
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
}
}
handleMouseLeave(): void {
this.previewVisible = false;
this.clearPreview();
// Clear overlay canvases when mouse leaves
this.canvasInstance.canvasRenderer.clearOverlay();
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
}
handleMouseEnter(): void {
@@ -908,11 +1008,18 @@ export class MaskTool {
if (!this.isActive) return;
if (this.isDrawing) {
this.isDrawing = false;
// Commit the stroke from overlay to actual mask chunks
this.commitStrokeToChunks();
// Clear stroke overlay and reset state
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.currentStrokePoints = [];
this.lastPosition = null;
this.currentDrawingChunk = null;
// After drawing is complete, update active canvas to show all chunks
this.updateActiveMaskCanvas(true); // forceShowAll = true
this.updateActiveMaskCanvas(true); // Force full update
this.completeMaskOperation();
this.drawBrushPreview(viewCoords);
@@ -932,6 +1039,44 @@ export class MaskTool {
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
}
/**
* Commits the current stroke from overlay to actual mask chunks
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
*/
private commitStrokeToChunks(): void {
if (this.currentStrokePoints.length < 2) {
return; // Need at least 2 points for a stroke
}
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
// Replay the entire stroke path with interpolation for smooth, accurate lines
for (let i = 1; i < this.currentStrokePoints.length; i++) {
const startPoint = this.currentStrokePoints[i - 1];
const endPoint = this.currentStrokePoints[i];
// Calculate distance between points
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.brushSize / 4) {
// Small distance - draw single segment
this.drawOnChunks(startPoint, endPoint);
} else {
// Large distance - interpolate for smooth line without gaps
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
// Draw all interpolated segments
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
}
}
}
log.debug("Stroke committed to chunks successfully with interpolation");
}
/**
* Draws a line between two world coordinates on the appropriate chunks
*/
@@ -982,15 +1127,15 @@ export class MaskTool {
const gradientRadius = this.brushSize / 2;
if (this.brushHardness === 1) {
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
if (this._brushHardness === 1) {
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
} else {
const innerRadius = gradientRadius * this.brushHardness;
const innerRadius = gradientRadius * this._brushHardness;
const gradient = chunk.ctx.createRadialGradient(
endLocal.x, endLocal.y, innerRadius,
endLocal.x, endLocal.y, gradientRadius
);
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
gradient.addColorStop(0, `rgba(255, 255, 255, ${this._brushStrength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
chunk.ctx.strokeStyle = gradient;
}
@@ -1029,29 +1174,17 @@ export class MaskTool {
}
/**
* Updates active canvas when drawing affects chunks with throttling to prevent lag
* During drawing, only updates the affected active chunks for performance
* Updates active canvas when drawing affects chunks
* Since we now use overlay during drawing, this is only called after drawing is complete
*/
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
// Calculate which chunks were affected by this drawing operation
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
// During drawing, only update affected chunks that are active for performance
if (this.isDrawing) {
// Use throttled partial update for active chunks only
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
} else {
// This method is now simplified - we only update after drawing is complete
// The overlay handles all live preview, so we don't need complex chunk activation
if (!this.isDrawing) {
// Not drawing - do full update to show all chunks
this.updateActiveMaskCanvas(true);
}
// During drawing, we don't update chunks at all - overlay handles preview
}
/**
@@ -1142,20 +1275,13 @@ export class MaskTool {
drawBrushPreview(viewCoords: Point): void {
if (!this.previewVisible || this.isDrawing) {
this.clearPreview();
this.canvasInstance.canvasRenderer.clearOverlay();
return;
}
this.clearPreview();
const zoom = this.canvasInstance.viewport.zoom;
const radius = (this.brushSize / 2) * zoom;
this.previewCtx.beginPath();
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
this.previewCtx.lineWidth = 1;
this.previewCtx.setLineDash([2, 4]);
this.previewCtx.stroke();
// Use overlay canvas instead of preview canvas for brush cursor
const worldCoords = this.canvasInstance.lastMousePosition;
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
}
clearPreview(): void {

View File

@@ -7,6 +7,7 @@ import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.j
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
import { convertToImage } from "./utils/ImageUtils.js";
import { updateNodePreview } from "./utils/PreviewUtils.js";
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
import type { ComfyNode } from './types';
const log = createModuleLogger('SAMDetectorIntegration');
@@ -376,6 +377,9 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
}
// Store original onClipspaceEditorSave function to restore later
let originalOnClipspaceEditorSave: (() => void) | null = null;
// Function to setup SAM Detector hook in menu options
export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
@@ -408,9 +412,46 @@ export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
node.imgs = [uploadResult.imageElement];
(node as any).clipspaceImg = uploadResult.imageElement;
// Ensure proper clipspace structure for updated ComfyUI
if (!ComfyApp.clipspace) {
ComfyApp.clipspace = {};
}
// Set up clipspace with proper indices
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
ComfyApp.clipspace.selectedIndex = 0;
ComfyApp.clipspace.combinedIndex = 0;
ComfyApp.clipspace.img_paste_mode = 'selected';
// Copy to ComfyUI clipspace
ComfyApp.copyToClipspace(node);
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
if (!originalOnClipspaceEditorSave) {
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
ComfyApp.onClipspaceEditorSave = function() {
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
// Use the unified clipspace validation function
const isValid = validateAndFixClipspace();
if (!isValid) {
log.error("Clipspace validation failed, cannot proceed with paste");
return;
}
// Call the original function
if (originalOnClipspaceEditorSave) {
originalOnClipspaceEditorSave.call(ComfyApp);
}
// Restore the original function after use
if (originalOnClipspaceEditorSave) {
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
originalOnClipspaceEditorSave = null;
}
};
}
// Start monitoring for SAM Detector results
startSAMDetectorMonitoring(node);

170
src/css/blend_mode_menu.css Normal file
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-weight: 550;
cursor: pointer;
transition: all 0.2s ease-in-out;
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
min-width: 80px;
text-align: center;
margin: 2px;
@@ -213,7 +213,7 @@
border-radius: 5px;
border: 1px solid #555;
cursor: pointer;
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
user-select: none;
padding: 0;
font-family: inherit;
@@ -346,6 +346,11 @@
background-color: #4a4a4a !important;
}
.clipboard-switch.disabled .switch-labels {
color: #777 !important;
}
.painter-separator {
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

@@ -28,8 +28,6 @@ export interface Layer {
width: number; // szerokość widocznego obszaru
height: number; // wysokość widocznego obszaru
};
blendedImageCache?: HTMLCanvasElement; // Cache for the pre-rendered blendArea effect
blendedImageDirty?: boolean; // Flag to invalidate the cache
}
export interface ComfyNode {

View File

@@ -1,6 +1,7 @@
import {createModuleLogger} from "./LoggerUtils.js";
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
import { safeClipspacePaste } from "./ClipspaceUtils.js";
// @ts-ignore
import {api} from "../../../scripts/api.js";
@@ -56,7 +57,13 @@ export class ClipboardManager {
*/
tryClipspacePaste = withErrorHandling(async (addMode: AddMode): Promise<boolean> => {
log.info("Attempting to paste from ComfyUI Clipspace");
ComfyApp.pasteFromClipspace(this.canvas.node);
// Use the unified clipspace validation and paste function
const pasteSuccess = safeClipspacePaste(this.canvas.node);
if (!pasteSuccess) {
log.debug("Safe clipspace paste failed");
return false;
}
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
const clipspaceImage = this.canvas.node.imgs[0];

114
src/utils/ClipspaceUtils.ts Normal file
View File

@@ -0,0 +1,114 @@
import { createModuleLogger } from "./LoggerUtils.js";
// @ts-ignore
import { ComfyApp } from "../../../scripts/app.js";
const log = createModuleLogger('ClipspaceUtils');
/**
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
*/
export function validateAndFixClipspace(): boolean {
log.debug("Validating and fixing clipspace structure");
// Check if clipspace exists
if (!ComfyApp.clipspace) {
log.debug("ComfyUI clipspace is not available");
return false;
}
// Validate clipspace structure
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
log.debug("ComfyUI clipspace has no images");
return false;
}
log.debug("Current clipspace state:", {
hasImgs: !!ComfyApp.clipspace.imgs,
imgsLength: ComfyApp.clipspace.imgs?.length,
selectedIndex: ComfyApp.clipspace.selectedIndex,
combinedIndex: ComfyApp.clipspace.combinedIndex,
img_paste_mode: ComfyApp.clipspace.img_paste_mode
});
// Ensure required indices are set
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
ComfyApp.clipspace.selectedIndex = 0;
log.debug("Fixed clipspace selectedIndex to 0");
}
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
ComfyApp.clipspace.combinedIndex = 0;
log.debug("Fixed clipspace combinedIndex to 0");
}
if (!ComfyApp.clipspace.img_paste_mode) {
ComfyApp.clipspace.img_paste_mode = 'selected';
log.debug("Fixed clipspace img_paste_mode to 'selected'");
}
// Ensure indices are within bounds
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
ComfyApp.clipspace.selectedIndex = maxIndex;
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
}
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
ComfyApp.clipspace.combinedIndex = maxIndex;
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
}
// Verify the image at combinedIndex exists and has src
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
if (!combinedImg || !combinedImg.src) {
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
// Try to use the first available image
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
ComfyApp.clipspace.combinedIndex = i;
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
break;
}
}
// Final check - if still no valid image found
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
if (!finalImg || !finalImg.src) {
log.error("No valid images found in clipspace after attempting fixes");
return false;
}
}
log.debug("Final clipspace structure:", {
selectedIndex: ComfyApp.clipspace.selectedIndex,
combinedIndex: ComfyApp.clipspace.combinedIndex,
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
imgsLength: ComfyApp.clipspace.imgs?.length,
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
});
return true;
}
/**
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
* @param {any} node - The ComfyUI node to paste to
* @returns {boolean} - True if paste was successful, false otherwise
*/
export function safeClipspacePaste(node: any): boolean {
log.debug("Attempting safe clipspace paste");
if (!validateAndFixClipspace()) {
log.debug("Clipspace validation failed, cannot paste");
return false;
}
try {
ComfyApp.pasteFromClipspace(node);
log.debug("Successfully called pasteFromClipspace");
return true;
} catch (error) {
log.error("Error calling pasteFromClipspace:", error);
return false;
}
}