mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cf74a3c2 | ||
|
|
00a39d756d | ||
|
|
d0e6bf8b3d | ||
|
|
da37900b33 | ||
|
|
64c5e49707 | ||
|
|
06d94f6a63 | ||
|
|
b21d6e3502 | ||
|
|
285ad035b2 | ||
|
|
949ffa0143 | ||
|
|
afdac52144 | ||
|
|
bf55d13f67 | ||
|
|
de83a884c2 | ||
|
|
dd2a81b6f2 | ||
|
|
176b9d03ac | ||
|
|
e4f44c10e8 | ||
|
|
11dd554204 | ||
|
|
9f21ff13ae | ||
|
|
1a1d8748cb | ||
|
|
38973b4698 | ||
|
|
1bd261bee0 | ||
|
|
df6979a59b | ||
|
|
2427f0bc5f | ||
|
|
3356c631bb | ||
|
|
3d34bfafd5 | ||
|
|
3c3e6934d7 | ||
|
|
84e1e4820c | ||
|
|
012368c52b | ||
|
|
82c42f99fe | ||
|
|
5da0855a52 | ||
|
|
ed9fdf5d60 | ||
|
|
d84b9385ad | ||
|
|
c4318d4923 |
@@ -4,16 +4,16 @@ import os
|
|||||||
# Add the custom node's directory to the Python path
|
# Add the custom node's directory to the Python path
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from .canvas_node import CanvasNode
|
from .canvas_node import LayerForgeNode
|
||||||
|
|
||||||
CanvasNode.setup_routes()
|
LayerForgeNode.setup_routes()
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"CanvasNode": CanvasNode
|
"LayerForgeNode": LayerForgeNode
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"CanvasNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
|
"LayerForgeNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./js"
|
WEB_DIRECTORY = "./js"
|
||||||
|
|||||||
150
canvas_node.py
150
canvas_node.py
@@ -90,7 +90,7 @@ class BiRefNet(torch.nn.Module):
|
|||||||
return [output]
|
return [output]
|
||||||
|
|
||||||
|
|
||||||
class CanvasNode:
|
class LayerForgeNode:
|
||||||
_canvas_data_storage = {}
|
_canvas_data_storage = {}
|
||||||
_storage_lock = threading.Lock()
|
_storage_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -179,6 +179,10 @@ class CanvasNode:
|
|||||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
||||||
"node_id": ("STRING", {"default": "0"}),
|
"node_id": ("STRING", {"default": "0"}),
|
||||||
},
|
},
|
||||||
|
"optional": {
|
||||||
|
"input_image": ("IMAGE",),
|
||||||
|
"input_mask": ("MASK",),
|
||||||
|
},
|
||||||
"hidden": {
|
"hidden": {
|
||||||
"prompt": ("PROMPT",),
|
"prompt": ("PROMPT",),
|
||||||
"unique_id": ("UNIQUE_ID",),
|
"unique_id": ("UNIQUE_ID",),
|
||||||
@@ -239,7 +243,7 @@ class CanvasNode:
|
|||||||
|
|
||||||
_processing_lock = threading.Lock()
|
_processing_lock = threading.Lock()
|
||||||
|
|
||||||
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, prompt=None, unique_id=None):
|
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, input_image=None, input_mask=None, prompt=None, unique_id=None):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
@@ -250,6 +254,81 @@ class CanvasNode:
|
|||||||
|
|
||||||
log_info(f"Lock acquired. Starting process_canvas_image for node_id: {node_id} (fallback unique_id: {unique_id})")
|
log_info(f"Lock acquired. Starting process_canvas_image for node_id: {node_id} (fallback unique_id: {unique_id})")
|
||||||
|
|
||||||
|
# Always store fresh input data, even if None, to clear stale data
|
||||||
|
log_info(f"Storing input data for node {node_id} - Image: {input_image is not None}, Mask: {input_mask is not None}")
|
||||||
|
|
||||||
|
with self.__class__._storage_lock:
|
||||||
|
input_data = {}
|
||||||
|
|
||||||
|
if input_image is not None:
|
||||||
|
# Convert image tensor(s) to base64 - handle batch
|
||||||
|
if isinstance(input_image, torch.Tensor):
|
||||||
|
# Ensure correct shape [B, H, W, C]
|
||||||
|
if input_image.dim() == 3:
|
||||||
|
input_image = input_image.unsqueeze(0)
|
||||||
|
|
||||||
|
batch_size = input_image.shape[0]
|
||||||
|
log_info(f"Processing batch of {batch_size} image(s)")
|
||||||
|
|
||||||
|
if batch_size == 1:
|
||||||
|
# Single image - keep backward compatibility
|
||||||
|
img_np = (input_image.squeeze(0).cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
pil_img = Image.fromarray(img_np, 'RGB')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
pil_img.save(buffered, format="PNG")
|
||||||
|
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||||
|
input_data['input_image'] = f"data:image/png;base64,{img_str}"
|
||||||
|
input_data['input_image_width'] = pil_img.width
|
||||||
|
input_data['input_image_height'] = pil_img.height
|
||||||
|
log_debug(f"Stored single input image: {pil_img.width}x{pil_img.height}")
|
||||||
|
else:
|
||||||
|
# Multiple images - store as array
|
||||||
|
images_array = []
|
||||||
|
for i in range(batch_size):
|
||||||
|
img_np = (input_image[i].cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
pil_img = Image.fromarray(img_np, 'RGB')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
pil_img.save(buffered, format="PNG")
|
||||||
|
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||||
|
images_array.append({
|
||||||
|
'data': f"data:image/png;base64,{img_str}",
|
||||||
|
'width': pil_img.width,
|
||||||
|
'height': pil_img.height
|
||||||
|
})
|
||||||
|
log_debug(f"Stored batch image {i+1}/{batch_size}: {pil_img.width}x{pil_img.height}")
|
||||||
|
|
||||||
|
input_data['input_images_batch'] = images_array
|
||||||
|
log_info(f"Stored batch of {batch_size} images")
|
||||||
|
|
||||||
|
if input_mask is not None:
|
||||||
|
# Convert mask tensor to base64
|
||||||
|
if isinstance(input_mask, torch.Tensor):
|
||||||
|
# Ensure correct shape
|
||||||
|
if input_mask.dim() == 2:
|
||||||
|
input_mask = input_mask.unsqueeze(0)
|
||||||
|
if input_mask.dim() == 3 and input_mask.shape[0] == 1:
|
||||||
|
input_mask = input_mask.squeeze(0)
|
||||||
|
|
||||||
|
# Convert to numpy and then to PIL
|
||||||
|
mask_np = (input_mask.cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
pil_mask = Image.fromarray(mask_np, 'L')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
mask_buffered = io.BytesIO()
|
||||||
|
pil_mask.save(mask_buffered, format="PNG")
|
||||||
|
mask_str = base64.b64encode(mask_buffered.getvalue()).decode()
|
||||||
|
input_data['input_mask'] = f"data:image/png;base64,{mask_str}"
|
||||||
|
log_debug(f"Stored input mask: {pil_mask.width}x{pil_mask.height}")
|
||||||
|
|
||||||
|
input_data['fit_on_add'] = fit_on_add
|
||||||
|
|
||||||
|
# Store in a special key for input data (overwrites any previous data)
|
||||||
|
self.__class__._canvas_data_storage[f"{node_id}_input"] = input_data
|
||||||
|
|
||||||
storage_key = node_id
|
storage_key = node_id
|
||||||
|
|
||||||
processed_image = None
|
processed_image = None
|
||||||
@@ -433,6 +512,63 @@ class CanvasNode:
|
|||||||
log_info("WebSocket connection closed")
|
log_info("WebSocket connection closed")
|
||||||
return ws
|
return ws
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.get("/layerforge/get_input_data/{node_id}")
|
||||||
|
async def get_input_data(request):
|
||||||
|
try:
|
||||||
|
node_id = request.match_info["node_id"]
|
||||||
|
log_debug(f"Checking for input data for node: {node_id}")
|
||||||
|
|
||||||
|
with cls._storage_lock:
|
||||||
|
input_key = f"{node_id}_input"
|
||||||
|
input_data = cls._canvas_data_storage.get(input_key, None)
|
||||||
|
|
||||||
|
if input_data:
|
||||||
|
log_info(f"Input data found for node {node_id}, sending to frontend")
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'has_input': True,
|
||||||
|
'data': input_data
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
log_debug(f"No input data found for node {node_id}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'has_input': False
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Error in get_input_data: {str(e)}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.post("/layerforge/clear_input_data/{node_id}")
|
||||||
|
async def clear_input_data(request):
|
||||||
|
try:
|
||||||
|
node_id = request.match_info["node_id"]
|
||||||
|
log_info(f"Clearing input data for node: {node_id}")
|
||||||
|
|
||||||
|
with cls._storage_lock:
|
||||||
|
input_key = f"{node_id}_input"
|
||||||
|
if input_key in cls._canvas_data_storage:
|
||||||
|
del cls._canvas_data_storage[input_key]
|
||||||
|
log_info(f"Input data cleared for node {node_id}")
|
||||||
|
else:
|
||||||
|
log_debug(f"No input data to clear for node {node_id}")
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Input data cleared for node {node_id}'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Error in clear_input_data: {str(e)}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
@PromptServer.instance.routes.get("/ycnode/get_canvas_data/{node_id}")
|
@PromptServer.instance.routes.get("/ycnode/get_canvas_data/{node_id}")
|
||||||
async def get_canvas_data(request):
|
async def get_canvas_data(request):
|
||||||
try:
|
try:
|
||||||
@@ -911,13 +1047,3 @@ def convert_tensor_to_base64(tensor, alpha_mask=None, original_alpha=None):
|
|||||||
log_error(f"Error in convert_tensor_to_base64: {str(e)}")
|
log_error(f"Error in convert_tensor_to_base64: {str(e)}")
|
||||||
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
|
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
CanvasNode.setup_routes()
|
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
|
||||||
"CanvasNode": CanvasNode
|
|
||||||
}
|
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
||||||
"CanvasNode": "LayerForge"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "d26732fd-91ea-4503-8d0d-383544823cec",
|
"id": "d26732fd-91ea-4503-8d0d-383544823cec",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"last_node_id": 49,
|
"last_node_id": 52,
|
||||||
"last_link_id": 112,
|
"last_link_id": 114,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"flags": {
|
"flags": {
|
||||||
"collapsed": true
|
"collapsed": true
|
||||||
},
|
},
|
||||||
"order": 6,
|
"order": 8,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
58
|
58
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 8,
|
"order": 10,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
26
|
26
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 7,
|
"order": 9,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -260,7 +260,7 @@
|
|||||||
46
|
46
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 12,
|
"order": 14,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
58
|
58
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 10,
|
"order": 12,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -344,7 +344,7 @@
|
|||||||
138
|
138
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 9,
|
"order": 11,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -365,12 +365,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pixels",
|
"name": "pixels",
|
||||||
"type": "IMAGE",
|
"type": "IMAGE",
|
||||||
"link": 106
|
"link": 113
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mask",
|
"name": "mask",
|
||||||
"type": "MASK",
|
"type": "MASK",
|
||||||
"link": 107
|
"link": 114
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
262
|
262
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 11,
|
"order": 13,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -462,7 +462,7 @@
|
|||||||
"widget_ue_connectable": {}
|
"widget_ue_connectable": {}
|
||||||
},
|
},
|
||||||
"widgets_values": [
|
"widgets_values": [
|
||||||
858769863184862,
|
1006953529460557,
|
||||||
"randomize",
|
"randomize",
|
||||||
20,
|
20,
|
||||||
1,
|
1,
|
||||||
@@ -526,7 +526,7 @@
|
|||||||
893.8499755859375
|
893.8499755859375
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 13,
|
"order": 15,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -550,15 +550,15 @@
|
|||||||
"id": 23,
|
"id": 23,
|
||||||
"type": "CLIPTextEncode",
|
"type": "CLIPTextEncode",
|
||||||
"pos": [
|
"pos": [
|
||||||
-835.4583129882812,
|
-905.195556640625,
|
||||||
878.8148193359375
|
924.5140991210938
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
311.0955810546875,
|
311.0955810546875,
|
||||||
108.43277740478516
|
108.43277740478516
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 5,
|
"order": 7,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -591,48 +591,94 @@
|
|||||||
"bgcolor": "#353"
|
"bgcolor": "#353"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 48,
|
"id": 51,
|
||||||
"type": "CanvasNode",
|
"type": "Note",
|
||||||
"pos": [
|
"pos": [
|
||||||
-514.2837524414062,
|
-916.8970947265625,
|
||||||
543.1272583007812
|
476.72564697265625
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
1862.893798828125,
|
350.92510986328125,
|
||||||
1237.79638671875
|
250.50831604003906
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 4,
|
"order": 4,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [],
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [
|
||||||
|
"How to Use Polygonal Selection\n- Start Drawing: Hold Shift + S and left-click to place the first point of your polygonal selection.\n\n- Add Points: Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.\n\n- Close Selection: Click back on the first point (or close to it) to complete and close the polygonal selection.\n\n- Run Inpainting: Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image."
|
||||||
|
],
|
||||||
|
"color": "#432",
|
||||||
|
"bgcolor": "#653"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 52,
|
||||||
|
"type": "Note",
|
||||||
|
"pos": [
|
||||||
|
-911.10205078125,
|
||||||
|
769.1378173828125
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
350.28143310546875,
|
||||||
|
99.23915100097656
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 5,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [
|
||||||
|
"Add a description at the bottom to tell the model what to generate."
|
||||||
|
],
|
||||||
|
"color": "#432",
|
||||||
|
"bgcolor": "#653"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"type": "LayerForgeNode",
|
||||||
|
"pos": [
|
||||||
|
-553.9073486328125,
|
||||||
|
478.2644348144531
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
1879.927490234375,
|
||||||
|
1259.4072265625
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 6,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
"name": "image",
|
"name": "image",
|
||||||
"type": "IMAGE",
|
"type": "IMAGE",
|
||||||
"links": [
|
"links": [
|
||||||
106
|
113
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mask",
|
"name": "mask",
|
||||||
"type": "MASK",
|
"type": "MASK",
|
||||||
"links": [
|
"links": [
|
||||||
107
|
114
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"cnr_id": "layerforge",
|
"cnr_id": "layerforge",
|
||||||
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
|
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
|
||||||
"Node name for S&R": "CanvasNode",
|
"Node name for S&R": "LayerForgeNode",
|
||||||
"widget_ue_connectable": {}
|
"widget_ue_connectable": {}
|
||||||
},
|
},
|
||||||
"widgets_values": [
|
"widgets_values": [
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
963,
|
18,
|
||||||
"48",
|
"50",
|
||||||
""
|
""
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -734,22 +780,6 @@
|
|||||||
0,
|
0,
|
||||||
"IMAGE"
|
"IMAGE"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
106,
|
|
||||||
48,
|
|
||||||
0,
|
|
||||||
38,
|
|
||||||
3,
|
|
||||||
"IMAGE"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
107,
|
|
||||||
48,
|
|
||||||
1,
|
|
||||||
38,
|
|
||||||
4,
|
|
||||||
"MASK"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
110,
|
110,
|
||||||
38,
|
38,
|
||||||
@@ -773,6 +803,22 @@
|
|||||||
8,
|
8,
|
||||||
0,
|
0,
|
||||||
"LATENT"
|
"LATENT"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
113,
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
38,
|
||||||
|
3,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
114,
|
||||||
|
50,
|
||||||
|
1,
|
||||||
|
38,
|
||||||
|
4,
|
||||||
|
"MASK"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"groups": [],
|
"groups": [],
|
||||||
@@ -781,8 +827,8 @@
|
|||||||
"ds": {
|
"ds": {
|
||||||
"scale": 0.6588450000000008,
|
"scale": 0.6588450000000008,
|
||||||
"offset": [
|
"offset": [
|
||||||
1318.77716124466,
|
1117.7398801488407,
|
||||||
-32.39290875553955
|
-110.40634975151642
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ue_links": [],
|
"ue_links": [],
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.7 MiB |
@@ -1,19 +1,137 @@
|
|||||||
{
|
{
|
||||||
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
|
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"last_node_id": 707,
|
"last_node_id": 710,
|
||||||
"last_link_id": 1499,
|
"last_link_id": 1505,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 708,
|
||||||
|
"type": "LayerForgeNode",
|
||||||
|
"pos": [
|
||||||
|
-3077.55615234375,
|
||||||
|
-3358.0537109375
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
1150,
|
||||||
|
1000
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [
|
||||||
|
1500
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mask",
|
||||||
|
"type": "MASK",
|
||||||
|
"links": [
|
||||||
|
1501
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "layerforge",
|
||||||
|
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
|
||||||
|
"widget_ue_connectable": {},
|
||||||
|
"Node name for S&R": "LayerForgeNode"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
11,
|
||||||
|
"708",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 709,
|
||||||
|
"type": "Reroute",
|
||||||
|
"pos": [
|
||||||
|
-1920.4510498046875,
|
||||||
|
-3559.688232421875
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
75,
|
||||||
|
26
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "*",
|
||||||
|
"link": 1500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [
|
||||||
|
1502,
|
||||||
|
1503
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"showOutputText": false,
|
||||||
|
"horizontal": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 710,
|
||||||
|
"type": "Reroute",
|
||||||
|
"pos": [
|
||||||
|
-1917.6273193359375,
|
||||||
|
-3524.312744140625
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
75,
|
||||||
|
26
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "*",
|
||||||
|
"link": 1501
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "MASK",
|
||||||
|
"links": [
|
||||||
|
1504,
|
||||||
|
1505
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"showOutputText": false,
|
||||||
|
"horizontal": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 369,
|
"id": 369,
|
||||||
"type": "PreviewImage",
|
"type": "PreviewImage",
|
||||||
"pos": [
|
"pos": [
|
||||||
-1699.1021728515625,
|
-1914.3177490234375,
|
||||||
-3355.60498046875
|
-2807.92724609375
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
660.91162109375,
|
710,
|
||||||
400.2092590332031
|
450
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 6,
|
"order": 6,
|
||||||
@@ -38,21 +156,21 @@
|
|||||||
"id": 606,
|
"id": 606,
|
||||||
"type": "PreviewImage",
|
"type": "PreviewImage",
|
||||||
"pos": [
|
"pos": [
|
||||||
-1911.126708984375,
|
-1913.4202880859375,
|
||||||
-2916.072998046875
|
-3428.773193359375
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
551.7399291992188,
|
700,
|
||||||
546.8018798828125
|
510
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 1,
|
"order": 3,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "images",
|
"name": "images",
|
||||||
"type": "IMAGE",
|
"type": "IMAGE",
|
||||||
"link": 1495
|
"link": 1503
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
@@ -64,92 +182,30 @@
|
|||||||
},
|
},
|
||||||
"widgets_values": []
|
"widgets_values": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": 603,
|
|
||||||
"type": "PreviewImage",
|
|
||||||
"pos": [
|
|
||||||
-1344.1650390625,
|
|
||||||
-2915.117919921875
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
601.4136962890625,
|
|
||||||
527.1531372070312
|
|
||||||
],
|
|
||||||
"flags": {},
|
|
||||||
"order": 4,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [
|
|
||||||
{
|
|
||||||
"name": "images",
|
|
||||||
"type": "IMAGE",
|
|
||||||
"link": 1236
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outputs": [],
|
|
||||||
"properties": {
|
|
||||||
"cnr_id": "comfy-core",
|
|
||||||
"ver": "0.3.34",
|
|
||||||
"Node name for S&R": "PreviewImage",
|
|
||||||
"widget_ue_connectable": {}
|
|
||||||
},
|
|
||||||
"widgets_values": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 680,
|
|
||||||
"type": "SaveImage",
|
|
||||||
"pos": [
|
|
||||||
-1025.9984130859375,
|
|
||||||
-3357.975341796875
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
278.8309020996094,
|
|
||||||
395.84002685546875
|
|
||||||
],
|
|
||||||
"flags": {},
|
|
||||||
"order": 5,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [
|
|
||||||
{
|
|
||||||
"name": "images",
|
|
||||||
"type": "IMAGE",
|
|
||||||
"link": 1465
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outputs": [],
|
|
||||||
"properties": {
|
|
||||||
"cnr_id": "comfy-core",
|
|
||||||
"ver": "0.3.34",
|
|
||||||
"Node name for S&R": "SaveImage",
|
|
||||||
"widget_ue_connectable": {}
|
|
||||||
},
|
|
||||||
"widgets_values": [
|
|
||||||
"ComfyUI"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": 442,
|
"id": 442,
|
||||||
"type": "JoinImageWithAlpha",
|
"type": "JoinImageWithAlpha",
|
||||||
"pos": [
|
"pos": [
|
||||||
-1902.5858154296875,
|
-1190.1787109375,
|
||||||
-3187.159423828125
|
-3237.75732421875
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
176.86483764648438,
|
176.86483764648438,
|
||||||
46
|
46
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 2,
|
"order": 5,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "image",
|
"name": "image",
|
||||||
"type": "IMAGE",
|
"type": "IMAGE",
|
||||||
"link": 1494
|
"link": 1502
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "alpha",
|
"name": "alpha",
|
||||||
"type": "MASK",
|
"type": "MASK",
|
||||||
"link": 1497
|
"link": 1505
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -170,25 +226,87 @@
|
|||||||
},
|
},
|
||||||
"widgets_values": []
|
"widgets_values": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": 603,
|
||||||
|
"type": "PreviewImage",
|
||||||
|
"pos": [
|
||||||
|
-1188.5968017578125,
|
||||||
|
-3143.6875
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
640,
|
||||||
|
510
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 7,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 1236
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "comfy-core",
|
||||||
|
"ver": "0.3.34",
|
||||||
|
"Node name for S&R": "PreviewImage",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 680,
|
||||||
|
"type": "SaveImage",
|
||||||
|
"pos": [
|
||||||
|
-536.2315673828125,
|
||||||
|
-3135.49755859375
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
279.97137451171875,
|
||||||
|
282
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 8,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 1465
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "comfy-core",
|
||||||
|
"ver": "0.3.34",
|
||||||
|
"Node name for S&R": "SaveImage",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"ComfyUI"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 706,
|
"id": 706,
|
||||||
"type": "MaskToImage",
|
"type": "MaskToImage",
|
||||||
"pos": [
|
"pos": [
|
||||||
-1901.433349609375,
|
-1911.38525390625,
|
||||||
-3332.2021484375
|
-2875.74658203125
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
184.62362670898438,
|
184.62362670898438,
|
||||||
26
|
26
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 3,
|
"order": 4,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "mask",
|
"name": "mask",
|
||||||
"type": "MASK",
|
"type": "MASK",
|
||||||
"link": 1498
|
"link": 1504
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -203,57 +321,10 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"cnr_id": "comfy-core",
|
"cnr_id": "comfy-core",
|
||||||
"ver": "0.3.44",
|
"ver": "0.3.44",
|
||||||
"widget_ue_connectable": {},
|
"Node name for S&R": "MaskToImage",
|
||||||
"Node name for S&R": "MaskToImage"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 697,
|
|
||||||
"type": "CanvasNode",
|
|
||||||
"pos": [
|
|
||||||
-2968.572998046875,
|
|
||||||
-3347.89306640625
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
1044.9053955078125,
|
|
||||||
980.680908203125
|
|
||||||
],
|
|
||||||
"flags": {},
|
|
||||||
"order": 0,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [],
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "image",
|
|
||||||
"type": "IMAGE",
|
|
||||||
"links": [
|
|
||||||
1494,
|
|
||||||
1495
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mask",
|
|
||||||
"type": "MASK",
|
|
||||||
"links": [
|
|
||||||
1497,
|
|
||||||
1498
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"cnr_id": "layerforge",
|
|
||||||
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
|
|
||||||
"Node name for S&R": "CanvasNode",
|
|
||||||
"widget_ue_connectable": {}
|
"widget_ue_connectable": {}
|
||||||
},
|
},
|
||||||
"widgets_values": [
|
"widgets_values": []
|
||||||
true,
|
|
||||||
false,
|
|
||||||
"697",
|
|
||||||
15,
|
|
||||||
"697",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -273,38 +344,6 @@
|
|||||||
0,
|
0,
|
||||||
"IMAGE"
|
"IMAGE"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
1494,
|
|
||||||
697,
|
|
||||||
0,
|
|
||||||
442,
|
|
||||||
0,
|
|
||||||
"IMAGE"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
1495,
|
|
||||||
697,
|
|
||||||
0,
|
|
||||||
606,
|
|
||||||
0,
|
|
||||||
"IMAGE"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
1497,
|
|
||||||
697,
|
|
||||||
1,
|
|
||||||
442,
|
|
||||||
1,
|
|
||||||
"MASK"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
1498,
|
|
||||||
697,
|
|
||||||
1,
|
|
||||||
706,
|
|
||||||
0,
|
|
||||||
"MASK"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
1499,
|
1499,
|
||||||
706,
|
706,
|
||||||
@@ -312,16 +351,64 @@
|
|||||||
369,
|
369,
|
||||||
0,
|
0,
|
||||||
"IMAGE"
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1500,
|
||||||
|
708,
|
||||||
|
0,
|
||||||
|
709,
|
||||||
|
0,
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1501,
|
||||||
|
708,
|
||||||
|
1,
|
||||||
|
710,
|
||||||
|
0,
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1502,
|
||||||
|
709,
|
||||||
|
0,
|
||||||
|
442,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1503,
|
||||||
|
709,
|
||||||
|
0,
|
||||||
|
606,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1504,
|
||||||
|
710,
|
||||||
|
0,
|
||||||
|
706,
|
||||||
|
0,
|
||||||
|
"MASK"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1505,
|
||||||
|
710,
|
||||||
|
0,
|
||||||
|
442,
|
||||||
|
1,
|
||||||
|
"MASK"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"groups": [],
|
"groups": [],
|
||||||
"config": {},
|
"config": {},
|
||||||
"extra": {
|
"extra": {
|
||||||
"ds": {
|
"ds": {
|
||||||
"scale": 0.9646149645000008,
|
"scale": 0.7972024500000005,
|
||||||
"offset": [
|
"offset": [
|
||||||
3002.5649125522764,
|
3208.3419155969927,
|
||||||
3543.443319064718
|
3617.011371212156
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ue_links": [],
|
"ue_links": [],
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 939 KiB After Width: | Height: | Size: 854 KiB |
@@ -123,10 +123,14 @@ export class BatchPreviewManager {
|
|||||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||||
if (this.maskWasVisible) {
|
if (this.maskWasVisible) {
|
||||||
this.canvas.maskTool.toggleOverlayVisibility();
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
|
||||||
if (toggleBtn) {
|
if (toggleSwitch) {
|
||||||
toggleBtn.classList.remove('primary');
|
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
|
||||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
|
if (checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
toggleSwitch.classList.remove('primary');
|
||||||
|
const iconContainer = toggleSwitch.querySelector('.switch-icon');
|
||||||
if (iconContainer) {
|
if (iconContainer) {
|
||||||
iconContainer.style.opacity = '0.5';
|
iconContainer.style.opacity = '0.5';
|
||||||
}
|
}
|
||||||
@@ -165,10 +169,14 @@ export class BatchPreviewManager {
|
|||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||||
this.canvas.maskTool.toggleOverlayVisibility();
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
|
||||||
if (toggleBtn) {
|
if (toggleSwitch) {
|
||||||
toggleBtn.classList.add('primary');
|
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
|
||||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
|
if (checkbox) {
|
||||||
|
checkbox.checked = true;
|
||||||
|
}
|
||||||
|
toggleSwitch.classList.add('primary');
|
||||||
|
const iconContainer = toggleSwitch.querySelector('.switch-icon');
|
||||||
if (iconContainer) {
|
if (iconContainer) {
|
||||||
iconContainer.style.opacity = '1';
|
iconContainer.style.opacity = '1';
|
||||||
}
|
}
|
||||||
|
|||||||
18
js/Canvas.js
18
js/Canvas.js
@@ -61,9 +61,20 @@ export class Canvas {
|
|||||||
});
|
});
|
||||||
this.offscreenCanvas = offscreenCanvas;
|
this.offscreenCanvas = offscreenCanvas;
|
||||||
this.offscreenCtx = offscreenCtx;
|
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.canvasContainer = null;
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
this.pendingDataCheck = null;
|
this.pendingDataCheck = null;
|
||||||
|
this.pendingInputDataCheck = null;
|
||||||
|
this.inputDataLoaded = false;
|
||||||
this.imageCache = new Map();
|
this.imageCache = new Map();
|
||||||
this.requestSaveState = () => { };
|
this.requestSaveState = () => { };
|
||||||
this.outputAreaShape = null;
|
this.outputAreaShape = null;
|
||||||
@@ -363,6 +374,10 @@ export class Canvas {
|
|||||||
return widget ? widget.value : false;
|
return widget ? widget.value : false;
|
||||||
};
|
};
|
||||||
const handleExecutionStart = () => {
|
const handleExecutionStart = () => {
|
||||||
|
// Check for input data when execution starts, but don't reset the flag
|
||||||
|
log.debug('Execution started, checking for input data...');
|
||||||
|
// On start, only allow images; mask should load on mask-connect or after execution completes
|
||||||
|
this.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: 'execution_start' });
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
lastExecutionStartTime = Date.now();
|
lastExecutionStartTime = Date.now();
|
||||||
// Store a snapshot of the context for the upcoming batch
|
// Store a snapshot of the context for the upcoming batch
|
||||||
@@ -385,6 +400,9 @@ export class Canvas {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleExecutionSuccess = async () => {
|
const handleExecutionSuccess = async () => {
|
||||||
|
// Always check for input data after execution completes
|
||||||
|
log.debug('Execution success, checking for input data...');
|
||||||
|
await this.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: 'execution_success' });
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
log.info('Auto-refresh triggered, importing latest images.');
|
log.info('Auto-refresh triggered, importing latest images.');
|
||||||
if (!this.pendingBatchContext) {
|
if (!this.pendingBatchContext) {
|
||||||
|
|||||||
542
js/CanvasIO.js
542
js/CanvasIO.js
@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||||
|
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
|
||||||
const log = createModuleLogger('CanvasIO');
|
const log = createModuleLogger('CanvasIO');
|
||||||
export class CanvasIO {
|
export class CanvasIO {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
@@ -238,23 +239,21 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
|
throw new Error(`Failed to get confirmation from server for node ${nodeId}. ` +
|
||||||
|
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
|
||||||
|
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
|
||||||
|
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async addInputToCanvas(inputImage, inputMask) {
|
async addInputToCanvas(inputImage, inputMask) {
|
||||||
try {
|
try {
|
||||||
log.debug("Adding input to canvas:", { inputImage });
|
log.debug("Adding input to canvas:", { inputImage });
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
// Use unified tensorToImageData for RGB image
|
||||||
if (!tempCtx)
|
const imageData = tensorToImageData(inputImage, 'rgb');
|
||||||
throw new Error("Could not create temp context");
|
if (!imageData)
|
||||||
const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height);
|
throw new Error("Failed to convert input image tensor");
|
||||||
tempCtx.putImageData(imgData, 0, 0);
|
// Create HTMLImageElement from ImageData
|
||||||
const image = new Image();
|
const image = await createImageFromImageData(imageData);
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
image.onload = resolve;
|
|
||||||
image.onerror = reject;
|
|
||||||
image.src = tempCanvas.toDataURL();
|
|
||||||
});
|
|
||||||
const bounds = this.canvas.outputAreaBounds;
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
|
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
|
||||||
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
||||||
@@ -280,17 +279,10 @@ export class CanvasIO {
|
|||||||
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
||||||
throw new Error("Invalid tensor data");
|
throw new Error("Invalid tensor data");
|
||||||
}
|
}
|
||||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
const imageData = tensorToImageData(tensor, 'rgb');
|
||||||
if (!ctx)
|
if (!imageData)
|
||||||
throw new Error("Could not create canvas context");
|
throw new Error("Failed to convert tensor to image data");
|
||||||
const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height);
|
return await createImageFromImageData(imageData);
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
|
|
||||||
img.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
log.error("Error converting tensor to image:", error);
|
log.error("Error converting tensor to image:", error);
|
||||||
@@ -311,12 +303,26 @@ export class CanvasIO {
|
|||||||
async initNodeData() {
|
async initNodeData() {
|
||||||
try {
|
try {
|
||||||
log.info("Starting node data initialization...");
|
log.info("Starting node data initialization...");
|
||||||
|
// First check for input data from the backend (new feature)
|
||||||
|
await this.checkForInputData();
|
||||||
|
// If we've already loaded input data, don't continue with old initialization
|
||||||
|
if (this.canvas.inputDataLoaded) {
|
||||||
|
log.debug("Input data already loaded, skipping old initialization");
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.canvas.node || !this.canvas.node.inputs) {
|
if (!this.canvas.node || !this.canvas.node.inputs) {
|
||||||
log.debug("Node or inputs not ready");
|
log.debug("Node or inputs not ready");
|
||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
const imageLinkId = this.canvas.node.inputs[0].link;
|
const imageLinkId = this.canvas.node.inputs[0].link;
|
||||||
|
// Check if we already loaded this link
|
||||||
|
if (this.canvas.lastLoadedLinkId === imageLinkId) {
|
||||||
|
log.debug(`Link ${imageLinkId} already loaded via new system, marking as initialized`);
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const imageData = window.app.nodeOutputs[imageLinkId];
|
const imageData = window.app.nodeOutputs[imageLinkId];
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
log.debug("Found image data:", imageData);
|
log.debug("Found image data:", imageData);
|
||||||
@@ -328,6 +334,10 @@ export class CanvasIO {
|
|||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
// No input connected, mark as initialized to stop repeated checks
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
}
|
||||||
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
||||||
const maskLinkId = this.canvas.node.inputs[1].link;
|
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||||
const maskData = window.app.nodeOutputs[maskLinkId];
|
const maskData = window.app.nodeOutputs[maskLinkId];
|
||||||
@@ -342,6 +352,390 @@ export class CanvasIO {
|
|||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async checkForInputData(options) {
|
||||||
|
try {
|
||||||
|
const nodeId = this.canvas.node.id;
|
||||||
|
const allowImage = options?.allowImage ?? true;
|
||||||
|
const allowMask = options?.allowMask ?? true;
|
||||||
|
const reason = options?.reason ?? 'unspecified';
|
||||||
|
log.info(`Checking for input data for node ${nodeId}... opts: image=${allowImage}, mask=${allowMask}, reason=${reason}`);
|
||||||
|
// Track loaded links separately for image and mask
|
||||||
|
let imageLoaded = false;
|
||||||
|
let maskLoaded = false;
|
||||||
|
let imageChanged = false;
|
||||||
|
// First, try to get data from connected node's output if available (IMAGES)
|
||||||
|
if (allowImage && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
|
const linkId = this.canvas.node.inputs[0].link;
|
||||||
|
const graph = this.canvas.node.graph;
|
||||||
|
// Always check if images have changed first
|
||||||
|
if (graph) {
|
||||||
|
const link = graph.links[linkId];
|
||||||
|
if (link) {
|
||||||
|
const sourceNode = graph.getNodeById(link.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// Create current batch identifier (all image sources combined)
|
||||||
|
const currentBatchImageSrcs = sourceNode.imgs.map((img) => img.src).join('|');
|
||||||
|
// Check if this is the same link we loaded before
|
||||||
|
if (this.canvas.lastLoadedLinkId === linkId) {
|
||||||
|
// Same link, check if images actually changed
|
||||||
|
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
|
||||||
|
log.info(`Batch images changed for link ${linkId} (${sourceNode.imgs.length} images), will reload...`);
|
||||||
|
log.debug(`Previous batch hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
|
||||||
|
log.debug(`Current batch hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
|
||||||
|
imageChanged = true;
|
||||||
|
// Clear the inputDataLoaded flag to force reload from backend
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
// Clear the lastLoadedImageSrc to force reload
|
||||||
|
this.canvas.lastLoadedImageSrc = undefined;
|
||||||
|
// Clear backend data to force fresh load
|
||||||
|
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
|
||||||
|
.then(() => log.debug("Backend input data cleared due to image change"))
|
||||||
|
.catch(err => log.error("Failed to clear backend data:", err));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug(`Batch images for link ${linkId} unchanged (${sourceNode.imgs.length} images)`);
|
||||||
|
imageLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Different link or first load
|
||||||
|
log.info(`New link ${linkId} detected, will load ${sourceNode.imgs.length} images`);
|
||||||
|
imageChanged = false; // It's not a change, it's a new link
|
||||||
|
imageLoaded = false; // Need to load
|
||||||
|
// Reset the inputDataLoaded flag for new link
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!imageLoaded || imageChanged) {
|
||||||
|
// Reset the inputDataLoaded flag when images change
|
||||||
|
if (imageChanged) {
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
log.info("Resetting inputDataLoaded flag due to image change");
|
||||||
|
}
|
||||||
|
if (this.canvas.node.graph) {
|
||||||
|
const graph2 = this.canvas.node.graph;
|
||||||
|
const link2 = graph2.links[linkId];
|
||||||
|
if (link2) {
|
||||||
|
const sourceNode = graph2.getNodeById(link2.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// The connected node has images in its output - handle multiple images (batch)
|
||||||
|
log.info(`Found ${sourceNode.imgs.length} image(s) in connected node's output, loading all`);
|
||||||
|
// Create a combined source identifier for batch detection
|
||||||
|
const batchImageSrcs = sourceNode.imgs.map((img) => img.src).join('|');
|
||||||
|
// Mark this link and batch sources as loaded
|
||||||
|
this.canvas.lastLoadedLinkId = linkId;
|
||||||
|
this.canvas.lastLoadedImageSrc = batchImageSrcs;
|
||||||
|
// Don't clear layers - just add new ones
|
||||||
|
if (imageChanged) {
|
||||||
|
log.info("Image change detected, will add new layers");
|
||||||
|
}
|
||||||
|
// Determine add mode
|
||||||
|
const fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
|
||||||
|
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
|
||||||
|
// Add all images from the batch as separate layers
|
||||||
|
for (let i = 0; i < sourceNode.imgs.length; i++) {
|
||||||
|
const img = sourceNode.imgs[i];
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, // Give each layer a unique name
|
||||||
|
addMode, this.canvas.outputAreaBounds);
|
||||||
|
log.debug(`Added batch image ${i + 1}/${sourceNode.imgs.length} to canvas`);
|
||||||
|
}
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
imageLoaded = true;
|
||||||
|
log.info(`All ${sourceNode.imgs.length} input images from batch added as separate layers`);
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for mask input separately (from nodeOutputs) ONLY when allowed
|
||||||
|
if (allowMask && this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
||||||
|
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||||
|
// Check if we already loaded this mask link
|
||||||
|
if (this.canvas.lastLoadedMaskLinkId === maskLinkId) {
|
||||||
|
log.debug(`Mask link ${maskLinkId} already loaded`);
|
||||||
|
maskLoaded = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Try to get mask tensor from nodeOutputs using origin_id (not link id)
|
||||||
|
const graph = this.canvas.node.graph;
|
||||||
|
let maskOutput = null;
|
||||||
|
if (graph) {
|
||||||
|
const link = graph.links[maskLinkId];
|
||||||
|
if (link && link.origin_id) {
|
||||||
|
// Use origin_id to get the actual node output
|
||||||
|
const nodeOutput = window.app?.nodeOutputs?.[link.origin_id];
|
||||||
|
log.debug(`Looking for mask output from origin node ${link.origin_id}, found:`, !!nodeOutput);
|
||||||
|
if (nodeOutput) {
|
||||||
|
log.debug(`Node ${link.origin_id} output structure:`, {
|
||||||
|
hasData: !!nodeOutput.data,
|
||||||
|
hasShape: !!nodeOutput.shape,
|
||||||
|
dataType: typeof nodeOutput.data,
|
||||||
|
shapeType: typeof nodeOutput.shape,
|
||||||
|
keys: Object.keys(nodeOutput)
|
||||||
|
});
|
||||||
|
// Only use if it has actual tensor data
|
||||||
|
if (nodeOutput.data && nodeOutput.shape) {
|
||||||
|
maskOutput = nodeOutput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maskOutput && maskOutput.data && maskOutput.shape) {
|
||||||
|
try {
|
||||||
|
// Derive dimensions from shape or explicit width/height
|
||||||
|
let width = maskOutput.width || 0;
|
||||||
|
let height = maskOutput.height || 0;
|
||||||
|
const shape = maskOutput.shape; // e.g. [1,H,W] or [1,H,W,1]
|
||||||
|
if ((!width || !height) && Array.isArray(shape)) {
|
||||||
|
if (shape.length >= 3) {
|
||||||
|
height = shape[1];
|
||||||
|
width = shape[2];
|
||||||
|
}
|
||||||
|
else if (shape.length === 2) {
|
||||||
|
height = shape[0];
|
||||||
|
width = shape[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!width || !height) {
|
||||||
|
throw new Error("Cannot determine mask dimensions from nodeOutputs");
|
||||||
|
}
|
||||||
|
// Determine channels count
|
||||||
|
let channels = 1;
|
||||||
|
if (Array.isArray(shape) && shape.length >= 4) {
|
||||||
|
channels = shape[3];
|
||||||
|
}
|
||||||
|
else if (maskOutput.channels) {
|
||||||
|
channels = maskOutput.channels;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const len = maskOutput.data.length;
|
||||||
|
channels = Math.max(1, Math.floor(len / (width * height)));
|
||||||
|
}
|
||||||
|
// Use unified tensorToImageData for masks
|
||||||
|
const maskImageData = tensorToImageData(maskOutput, 'grayscale');
|
||||||
|
if (!maskImageData)
|
||||||
|
throw new Error("Failed to convert mask tensor to image data");
|
||||||
|
// Create canvas and put image data
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create mask context");
|
||||||
|
ctx.putImageData(maskImageData, 0, 0);
|
||||||
|
// Convert to HTMLImageElement
|
||||||
|
const maskImg = await createImageFromSource(maskCanvas.toDataURL());
|
||||||
|
// Respect fit_on_add (scale to output area)
|
||||||
|
const widgets = this.canvas.node.widgets;
|
||||||
|
const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null;
|
||||||
|
const shouldFit = fitOnAddWidget && fitOnAddWidget.value;
|
||||||
|
let finalMaskImg = maskImg;
|
||||||
|
if (shouldFit) {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
|
// Apply to MaskTool (centers internally)
|
||||||
|
if (this.canvas.maskTool) {
|
||||||
|
this.canvas.maskTool.setMask(finalMaskImg, true);
|
||||||
|
this.canvas.maskAppliedFromInput = true;
|
||||||
|
this.canvas.canvasState.saveMaskState();
|
||||||
|
this.canvas.render();
|
||||||
|
// Mark this mask link as loaded to avoid re-applying
|
||||||
|
this.canvas.lastLoadedMaskLinkId = maskLinkId;
|
||||||
|
maskLoaded = true;
|
||||||
|
log.info("Applied input mask from nodeOutputs immediately on connection" + (shouldFit ? " (fitted to output area)" : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
log.warn("Failed to apply mask from nodeOutputs immediately; will wait for backend input_mask after execution", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// nodeOutputs exist but don't have tensor data yet (need workflow execution)
|
||||||
|
log.info(`Mask node ${this.canvas.node.graph?.links[maskLinkId]?.origin_id} found but has no tensor data yet. Mask will be applied automatically after workflow execution.`);
|
||||||
|
// Don't retry - data won't be available until workflow runs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only check backend if we have actual inputs connected
|
||||||
|
const hasImageInput = this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link;
|
||||||
|
const hasMaskInput = this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link;
|
||||||
|
// If mask input is disconnected, clear any currently applied mask to ensure full separation
|
||||||
|
if (!hasMaskInput) {
|
||||||
|
this.canvas.maskAppliedFromInput = false;
|
||||||
|
this.canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Mask input disconnected - cleared mask to enforce separation from input_image");
|
||||||
|
}
|
||||||
|
if (!hasImageInput && !hasMaskInput) {
|
||||||
|
log.debug("No inputs connected, skipping backend check");
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Skip backend check during mask connection if we didn't get immediate data
|
||||||
|
if (reason === "mask_connect" && !maskLoaded) {
|
||||||
|
log.info("No immediate mask data available during connection, skipping backend check to avoid stale data. Will check after execution.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check backend for input data only if we have connected inputs
|
||||||
|
const response = await fetch(`/layerforge/get_input_data/${nodeId}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.has_input) {
|
||||||
|
// Dedupe: skip only if backend payload matches last loaded batch hash
|
||||||
|
let backendBatchHash;
|
||||||
|
if (result.data?.input_images_batch && Array.isArray(result.data.input_images_batch)) {
|
||||||
|
backendBatchHash = result.data.input_images_batch.map((i) => i.data).join('|');
|
||||||
|
}
|
||||||
|
else if (result.data?.input_image) {
|
||||||
|
backendBatchHash = result.data.input_image;
|
||||||
|
}
|
||||||
|
// Check mask separately - don't skip if only images are unchanged AND mask is actually connected AND allowed
|
||||||
|
const shouldCheckMask = hasMaskInput && allowMask;
|
||||||
|
if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && !shouldCheckMask) {
|
||||||
|
log.debug("Backend input data unchanged and no mask to check, skipping reload");
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && shouldCheckMask) {
|
||||||
|
log.debug("Images unchanged but need to check mask, continuing...");
|
||||||
|
imageLoaded = true; // Mark images as already loaded to skip reloading them
|
||||||
|
}
|
||||||
|
// Check if we already loaded image data (by checking the current link)
|
||||||
|
if (allowImage && !imageLoaded && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
|
const currentLinkId = this.canvas.node.inputs[0].link;
|
||||||
|
if (this.canvas.lastLoadedLinkId !== currentLinkId) {
|
||||||
|
// Mark this link as loaded
|
||||||
|
this.canvas.lastLoadedLinkId = currentLinkId;
|
||||||
|
imageLoaded = false; // Will load from backend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for mask data from backend ONLY when mask input is actually connected AND allowed
|
||||||
|
// Only reset if the mask link actually changed
|
||||||
|
if (allowMask && hasMaskInput && this.canvas.node.inputs && this.canvas.node.inputs[1]) {
|
||||||
|
const currentMaskLinkId = this.canvas.node.inputs[1].link;
|
||||||
|
// Only reset if this is a different mask link than what we loaded before
|
||||||
|
if (this.canvas.lastLoadedMaskLinkId !== currentMaskLinkId) {
|
||||||
|
maskLoaded = false;
|
||||||
|
log.debug(`New mask input detected (${currentMaskLinkId}), will check backend for mask data`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug(`Same mask input (${currentMaskLinkId}), mask already loaded`);
|
||||||
|
maskLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// No mask input connected, or mask loading not allowed right now
|
||||||
|
maskLoaded = true; // Mark as loaded to skip mask processing
|
||||||
|
if (!allowMask) {
|
||||||
|
log.debug("Mask loading is currently disabled by caller, skipping mask check");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug("No mask input connected, skipping mask check");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("Input data found from backend, adding to canvas");
|
||||||
|
const inputData = result.data;
|
||||||
|
// Compute backend batch hash for dedupe and state
|
||||||
|
let backendHashNow;
|
||||||
|
if (inputData?.input_images_batch && Array.isArray(inputData.input_images_batch)) {
|
||||||
|
backendHashNow = inputData.input_images_batch.map((i) => i.data).join('|');
|
||||||
|
}
|
||||||
|
else if (inputData?.input_image) {
|
||||||
|
backendHashNow = inputData.input_image;
|
||||||
|
}
|
||||||
|
// Just update the hash without removing any layers
|
||||||
|
if (backendHashNow) {
|
||||||
|
log.info("New backend input data detected, adding new layers");
|
||||||
|
this.canvas.lastLoadedImageSrc = backendHashNow;
|
||||||
|
}
|
||||||
|
// Mark that we've loaded input data for this execution
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
// Determine add mode based on fit_on_add setting
|
||||||
|
const widgets = this.canvas.node.widgets;
|
||||||
|
const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null;
|
||||||
|
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
|
||||||
|
// Load input image(s) only if image input is actually connected, not already loaded, and allowed
|
||||||
|
if (allowImage && !imageLoaded && hasImageInput) {
|
||||||
|
if (inputData.input_images_batch) {
|
||||||
|
// Handle batch of images
|
||||||
|
const batch = inputData.input_images_batch;
|
||||||
|
log.info(`Processing batch of ${batch.length} images from backend`);
|
||||||
|
for (let i = 0; i < batch.length; i++) {
|
||||||
|
const imgData = batch[i];
|
||||||
|
const img = await createImageFromSource(imgData.data);
|
||||||
|
// Add image to canvas with unique name
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, addMode, this.canvas.outputAreaBounds);
|
||||||
|
log.debug(`Added batch image ${i + 1}/${batch.length} from backend`);
|
||||||
|
}
|
||||||
|
log.info(`All ${batch.length} batch images added from backend`);
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
else if (inputData.input_image) {
|
||||||
|
// Handle single image (backward compatibility)
|
||||||
|
const img = await createImageFromSource(inputData.input_image);
|
||||||
|
// Add image to canvas at output area position
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode, this.canvas.outputAreaBounds);
|
||||||
|
log.info("Single input image added as new layer to canvas");
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug("No input image data from backend");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!hasImageInput && (inputData.input_images_batch || inputData.input_image)) {
|
||||||
|
log.debug("Backend has image data but no image input connected, skipping image load");
|
||||||
|
}
|
||||||
|
// Handle mask separately only if mask input is actually connected, allowed, and not already loaded
|
||||||
|
if (allowMask && !maskLoaded && hasMaskInput && inputData.input_mask) {
|
||||||
|
log.info("Processing input mask");
|
||||||
|
// Load mask image
|
||||||
|
const maskImg = await createImageFromSource(inputData.input_mask);
|
||||||
|
// Determine if we should fit the mask or use it at original size
|
||||||
|
const fitOnAddWidget2 = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
|
||||||
|
const shouldFit = fitOnAddWidget2 && fitOnAddWidget2.value;
|
||||||
|
let finalMaskImg = maskImg;
|
||||||
|
if (shouldFit && this.canvas.maskTool) {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
|
// Apply to MaskTool (centers internally)
|
||||||
|
if (this.canvas.maskTool) {
|
||||||
|
this.canvas.maskTool.setMask(finalMaskImg, true);
|
||||||
|
}
|
||||||
|
this.canvas.maskAppliedFromInput = true;
|
||||||
|
// Save the mask state
|
||||||
|
this.canvas.canvasState.saveMaskState();
|
||||||
|
log.info("Applied input mask to mask tool" + (shouldFit ? " (fitted to output area)" : " (original size)"));
|
||||||
|
}
|
||||||
|
else if (!hasMaskInput && inputData.input_mask) {
|
||||||
|
log.debug("Backend has mask data but no mask input connected, skipping mask load");
|
||||||
|
}
|
||||||
|
else if (!allowMask && inputData.input_mask) {
|
||||||
|
log.debug("Mask input data present in backend but mask loading is disabled by caller; skipping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug("No input data from backend");
|
||||||
|
// Don't schedule another check - we'll only check when explicitly triggered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Error checking for input data:", error);
|
||||||
|
// Don't schedule another check on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleInputDataCheck() {
|
||||||
|
// Schedule a retry for mask data check when nodeOutputs are not ready yet
|
||||||
|
if (this.canvas.pendingInputDataCheck) {
|
||||||
|
clearTimeout(this.canvas.pendingInputDataCheck);
|
||||||
|
}
|
||||||
|
this.canvas.pendingInputDataCheck = window.setTimeout(() => {
|
||||||
|
this.canvas.pendingInputDataCheck = null;
|
||||||
|
log.debug("Retrying input data check for mask...");
|
||||||
|
}, 500); // Shorter delay for mask data retry
|
||||||
|
}
|
||||||
scheduleDataCheck() {
|
scheduleDataCheck() {
|
||||||
if (this.canvas.pendingDataCheck) {
|
if (this.canvas.pendingDataCheck) {
|
||||||
clearTimeout(this.canvas.pendingDataCheck);
|
clearTimeout(this.canvas.pendingDataCheck);
|
||||||
@@ -420,51 +814,10 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
convertTensorToImageData(tensor) {
|
convertTensorToImageData(tensor) {
|
||||||
try {
|
return tensorToImageData(tensor, 'rgb');
|
||||||
const shape = tensor.shape;
|
|
||||||
const height = shape[1];
|
|
||||||
const width = shape[2];
|
|
||||||
const channels = shape[3];
|
|
||||||
log.debug("Converting tensor:", {
|
|
||||||
shape: shape,
|
|
||||||
dataRange: {
|
|
||||||
min: tensor.min_val,
|
|
||||||
max: tensor.max_val
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const imageData = new ImageData(width, height);
|
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
|
||||||
const flatData = tensor.data;
|
|
||||||
const pixelCount = width * height;
|
|
||||||
for (let i = 0; i < pixelCount; i++) {
|
|
||||||
const pixelIndex = i * 4;
|
|
||||||
const tensorIndex = i * channels;
|
|
||||||
for (let c = 0; c < channels; c++) {
|
|
||||||
const value = flatData[tensorIndex + c];
|
|
||||||
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
|
|
||||||
data[pixelIndex + c] = Math.round(normalizedValue * 255);
|
|
||||||
}
|
|
||||||
data[pixelIndex + 3] = 255;
|
|
||||||
}
|
|
||||||
imageData.data.set(data);
|
|
||||||
return imageData;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
log.error("Error converting tensor:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async createImageFromData(imageData) {
|
async createImageFromData(imageData) {
|
||||||
return new Promise((resolve, reject) => {
|
return createImageFromImageData(imageData);
|
||||||
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
|
||||||
if (!ctx)
|
|
||||||
throw new Error("Could not create canvas context");
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
async processMaskData(maskData) {
|
async processMaskData(maskData) {
|
||||||
try {
|
try {
|
||||||
@@ -524,12 +877,7 @@ export class CanvasIO {
|
|||||||
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
||||||
const newLayers = [];
|
const newLayers = [];
|
||||||
for (const imageData of result.images) {
|
for (const imageData of result.images) {
|
||||||
const img = new Image();
|
const img = await createImageFromSource(imageData);
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
img.onload = resolve;
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = imageData;
|
|
||||||
});
|
|
||||||
let processedImage = img;
|
let processedImage = img;
|
||||||
// If there's a custom shape, clip the image to that shape
|
// If there's a custom shape, clip the image to that shape
|
||||||
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
|
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
|
||||||
@@ -556,33 +904,27 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async clipImageToShape(image, shape) {
|
async clipImageToShape(image, shape) {
|
||||||
return new Promise((resolve, reject) => {
|
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
if (!ctx) {
|
||||||
if (!ctx) {
|
throw new Error("Could not create canvas context for clipping");
|
||||||
reject(new Error("Could not create canvas context for clipping"));
|
}
|
||||||
return;
|
// Draw the image first
|
||||||
}
|
ctx.drawImage(image, 0, 0);
|
||||||
// Draw the image first
|
// Calculate custom shape position accounting for extensions
|
||||||
ctx.drawImage(image, 0, 0);
|
// Custom shape should maintain its relative position within the original canvas area
|
||||||
// Calculate custom shape position accounting for extensions
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
// Custom shape should maintain its relative position within the original canvas area
|
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
||||||
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
// Create a clipping mask using the shape with extension offset
|
||||||
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
// Create a clipping mask using the shape with extension offset
|
ctx.beginPath();
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||||
ctx.beginPath();
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
||||||
for (let i = 1; i < shape.points.length; i++) {
|
}
|
||||||
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
ctx.closePath();
|
||||||
}
|
ctx.fill();
|
||||||
ctx.closePath();
|
// Create a new image from the clipped canvas
|
||||||
ctx.fill();
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
// Create a new image from the clipped canvas
|
|
||||||
const clippedImage = new Image();
|
|
||||||
clippedImage.onload = () => resolve(clippedImage);
|
|
||||||
clippedImage.onerror = () => reject(new Error("Failed to create clipped image"));
|
|
||||||
clippedImage.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,33 @@ import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
|
|||||||
const log = createModuleLogger('CanvasInteractions');
|
const log = createModuleLogger('CanvasInteractions');
|
||||||
export class CanvasInteractions {
|
export class CanvasInteractions {
|
||||||
constructor(canvas) {
|
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.canvas = canvas;
|
||||||
this.interaction = {
|
this.interaction = {
|
||||||
mode: 'none',
|
mode: 'none',
|
||||||
panStart: { x: 0, y: 0 },
|
panStart: { x: 0, y: 0 },
|
||||||
dragStart: { x: 0, y: 0 },
|
dragStart: { x: 0, y: 0 },
|
||||||
transformOrigin: {},
|
transformOrigin: null,
|
||||||
resizeHandle: null,
|
resizeHandle: null,
|
||||||
resizeAnchor: { x: 0, y: 0 },
|
resizeAnchor: { x: 0, y: 0 },
|
||||||
canvasResizeStart: { x: 0, y: 0 },
|
canvasResizeStart: { x: 0, y: 0 },
|
||||||
isCtrlPressed: false,
|
isCtrlPressed: false,
|
||||||
|
isMetaPressed: false,
|
||||||
isAltPressed: false,
|
isAltPressed: false,
|
||||||
isShiftPressed: false,
|
isShiftPressed: false,
|
||||||
isSPressed: false,
|
isSPressed: false,
|
||||||
@@ -32,18 +49,29 @@ export class CanvasInteractions {
|
|||||||
view: this.canvas.getMouseViewCoordinates(e)
|
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) {
|
preventEventDefaults(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
performZoomOperation(worldCoords, zoomFactor) {
|
performZoomOperation(worldCoords, zoomFactor) {
|
||||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
|
||||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
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 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));
|
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
||||||
this.canvas.viewport.zoom = newZoom;
|
this.canvas.viewport.zoom = newZoom;
|
||||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / 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?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
renderAndSave(shouldSave = false) {
|
renderAndSave(shouldSave = false) {
|
||||||
@@ -64,29 +92,39 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown);
|
||||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
|
||||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp);
|
||||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
|
this.canvas.canvas.addEventListener('wheel', this.onWheel, { passive: false });
|
||||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
this.canvas.canvas.addEventListener('keydown', this.onKeyDown);
|
||||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
this.canvas.canvas.addEventListener('keyup', this.onKeyUp);
|
||||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
|
||||||
// Add a blur event listener to the window to reset key states
|
// Add a blur event listener to the window to reset key states
|
||||||
window.addEventListener('blur', this.handleBlur.bind(this));
|
window.addEventListener('blur', this.onBlur);
|
||||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
document.addEventListener('paste', this.onPaste);
|
||||||
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
|
||||||
this.canvas.isMouseOver = true;
|
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
|
||||||
this.handleMouseEnter(e);
|
this.canvas.canvas.addEventListener('dragover', this.onDragOver);
|
||||||
});
|
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter);
|
||||||
this.canvas.canvas.addEventListener('mouseleave', (e) => {
|
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave);
|
||||||
this.canvas.isMouseOver = false;
|
this.canvas.canvas.addEventListener('drop', this.onDrop);
|
||||||
this.handleMouseLeave(e);
|
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu);
|
||||||
});
|
}
|
||||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
|
teardownEventListeners() {
|
||||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove);
|
||||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
|
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp);
|
||||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
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
|
* Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów
|
||||||
@@ -124,9 +162,10 @@ export class CanvasInteractions {
|
|||||||
handleMouseDown(e) {
|
handleMouseDown(e) {
|
||||||
this.canvas.canvas.focus();
|
this.canvas.canvas.focus();
|
||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
||||||
this.canvas.render();
|
// Don't render here - mask tool will handle its own drawing
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.canvas.shapeTool.isActive) {
|
if (this.canvas.shapeTool.isActive) {
|
||||||
@@ -135,11 +174,11 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||||
if (e.shiftKey && e.ctrlKey) {
|
if (mods.shift && mods.ctrl) {
|
||||||
this.startCanvasMove(coords.world);
|
this.startCanvasMove(coords.world);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
// Clear custom shape when starting canvas resize
|
// Clear custom shape when starting canvas resize
|
||||||
if (this.canvas.outputAreaShape) {
|
if (this.canvas.outputAreaShape) {
|
||||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||||
@@ -163,7 +202,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0) { // Środkowy przycisk
|
if (e.button === 1) { // Środkowy przycisk
|
||||||
this.startPanning(e);
|
this.startPanning(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -179,7 +218,7 @@ export class CanvasInteractions {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||||
this.startPanningOrClearSelection(e);
|
this.startPanning(e, true); // clearSelection = true
|
||||||
}
|
}
|
||||||
handleMouseMove(e) {
|
handleMouseMove(e) {
|
||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
@@ -199,7 +238,7 @@ export class CanvasInteractions {
|
|||||||
switch (this.interaction.mode) {
|
switch (this.interaction.mode) {
|
||||||
case 'drawingMask':
|
case 'drawingMask':
|
||||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
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;
|
break;
|
||||||
case 'panning':
|
case 'panning':
|
||||||
this.panViewport(e);
|
this.panViewport(e);
|
||||||
@@ -221,6 +260,10 @@ export class CanvasInteractions {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
this.updateCursor(coords.world);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
||||||
@@ -232,6 +275,7 @@ export class CanvasInteractions {
|
|||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||||
|
// Render only once after drawing is complete
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -245,6 +289,14 @@ export class CanvasInteractions {
|
|||||||
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
this.logDragCompletion(coords);
|
this.logDragCompletion(coords);
|
||||||
}
|
}
|
||||||
|
// Handle end of crop bounds transformation before resetting interaction state
|
||||||
|
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
|
||||||
|
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
|
||||||
|
}
|
||||||
|
// Handle end of scale transformation (normal transform mode) before resetting interaction state
|
||||||
|
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
|
||||||
|
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
|
||||||
|
}
|
||||||
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
||||||
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
||||||
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
||||||
@@ -307,8 +359,17 @@ export class CanvasInteractions {
|
|||||||
this.performZoomOperation(coords.world, zoomFactor);
|
this.performZoomOperation(coords.world, zoomFactor);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Layer transformation when layers are selected
|
// Check if mouse is over any selected layer
|
||||||
this.handleLayerWheelTransformation(e);
|
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();
|
this.canvas.render();
|
||||||
if (!this.canvas.maskTool.isActive) {
|
if (!this.canvas.maskTool.isActive) {
|
||||||
@@ -316,14 +377,15 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleLayerWheelTransformation(e) {
|
handleLayerWheelTransformation(e) {
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||||
const direction = e.deltaY < 0 ? 1 : -1;
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
|
||||||
}
|
}
|
||||||
else {
|
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.height *= scaleFactor;
|
||||||
layer.x += (oldWidth - layer.width) / 2;
|
layer.x += (oldWidth - layer.width) / 2;
|
||||||
layer.y += (oldHeight - layer.height) / 2;
|
layer.y += (oldHeight - layer.height) / 2;
|
||||||
|
// Handle wheel scaling end for layers with blend area
|
||||||
|
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
calculateGridBasedScaling(oldHeight, deltaY) {
|
calculateGridBasedScaling(oldHeight, deltaY) {
|
||||||
const gridSize = 64;
|
const gridSize = 64; // Grid size - could be made configurable in the future
|
||||||
const direction = deltaY > 0 ? -1 : 1;
|
const direction = deltaY > 0 ? -1 : 1;
|
||||||
let targetHeight;
|
let targetHeight;
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
@@ -391,6 +455,8 @@ export class CanvasInteractions {
|
|||||||
handleKeyDown(e) {
|
handleKeyDown(e) {
|
||||||
if (e.key === 'Control')
|
if (e.key === 'Control')
|
||||||
this.interaction.isCtrlPressed = true;
|
this.interaction.isCtrlPressed = true;
|
||||||
|
if (e.key === 'Meta')
|
||||||
|
this.interaction.isMetaPressed = true;
|
||||||
if (e.key === 'Shift')
|
if (e.key === 'Shift')
|
||||||
this.interaction.isShiftPressed = true;
|
this.interaction.isShiftPressed = true;
|
||||||
if (e.key === 'Alt') {
|
if (e.key === 'Alt') {
|
||||||
@@ -408,11 +474,12 @@ export class CanvasInteractions {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
// 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;
|
let handled = true;
|
||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case 'z':
|
case 'z':
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.canvas.redo();
|
this.canvas.redo();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -439,7 +506,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
const step = e.shiftKey ? 10 : 1;
|
const step = mods.shift ? 10 : 1;
|
||||||
let needsRender = false;
|
let needsRender = false;
|
||||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||||
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||||
@@ -475,6 +542,8 @@ export class CanvasInteractions {
|
|||||||
handleKeyUp(e) {
|
handleKeyUp(e) {
|
||||||
if (e.key === 'Control')
|
if (e.key === 'Control')
|
||||||
this.interaction.isCtrlPressed = false;
|
this.interaction.isCtrlPressed = false;
|
||||||
|
if (e.key === 'Meta')
|
||||||
|
this.interaction.isMetaPressed = false;
|
||||||
if (e.key === 'Shift')
|
if (e.key === 'Shift')
|
||||||
this.interaction.isShiftPressed = false;
|
this.interaction.isShiftPressed = false;
|
||||||
if (e.key === 'Alt')
|
if (e.key === 'Alt')
|
||||||
@@ -494,6 +563,7 @@ export class CanvasInteractions {
|
|||||||
handleBlur() {
|
handleBlur() {
|
||||||
log.debug('Window lost focus, resetting key states.');
|
log.debug('Window lost focus, resetting key states.');
|
||||||
this.interaction.isCtrlPressed = false;
|
this.interaction.isCtrlPressed = false;
|
||||||
|
this.interaction.isMetaPressed = false;
|
||||||
this.interaction.isAltPressed = false;
|
this.interaction.isAltPressed = false;
|
||||||
this.interaction.isShiftPressed = false;
|
this.interaction.isShiftPressed = false;
|
||||||
this.interaction.isSPressed = false;
|
this.interaction.isSPressed = false;
|
||||||
@@ -515,6 +585,11 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateCursor(worldCoords) {
|
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);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
const handleName = transformTarget.handle;
|
const handleName = transformTarget.handle;
|
||||||
@@ -562,7 +637,9 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
prepareForDrag(layer, worldCoords) {
|
prepareForDrag(layer, worldCoords) {
|
||||||
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
// 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);
|
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||||
@@ -580,10 +657,9 @@ export class CanvasInteractions {
|
|||||||
this.interaction.mode = 'potential-drag';
|
this.interaction.mode = 'potential-drag';
|
||||||
this.interaction.dragStart = { ...worldCoords };
|
this.interaction.dragStart = { ...worldCoords };
|
||||||
}
|
}
|
||||||
startPanningOrClearSelection(e) {
|
startPanning(e, clearSelection = true) {
|
||||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
// Unified panning method - can optionally clear selection
|
||||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
if (clearSelection && !this.interaction.isCtrlPressed) {
|
||||||
if (!this.interaction.isCtrlPressed) {
|
|
||||||
this.canvas.canvasSelection.updateSelection([]);
|
this.canvas.canvasSelection.updateSelection([]);
|
||||||
}
|
}
|
||||||
this.interaction.mode = 'panning';
|
this.interaction.mode = 'panning';
|
||||||
@@ -632,19 +708,16 @@ export class CanvasInteractions {
|
|||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.saveState();
|
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) {
|
panViewport(e) {
|
||||||
const dx = e.clientX - this.interaction.panStart.x;
|
const dx = e.clientX - this.interaction.panStart.x;
|
||||||
const dy = e.clientY - this.interaction.panStart.y;
|
const dy = e.clientY - this.interaction.panStart.y;
|
||||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||||
|
// Update stroke overlay if mask tool is drawing during pan
|
||||||
|
if (this.canvas.maskTool.isDrawing) {
|
||||||
|
this.canvas.maskTool.handleViewportChange();
|
||||||
|
}
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.onViewportChange?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
@@ -699,7 +772,7 @@ export class CanvasInteractions {
|
|||||||
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||||
}
|
}
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
|
if (!o)
|
||||||
return;
|
return;
|
||||||
const handle = this.interaction.resizeHandle;
|
const handle = this.interaction.resizeHandle;
|
||||||
const anchor = this.interaction.resizeAnchor;
|
const anchor = this.interaction.resizeAnchor;
|
||||||
@@ -732,8 +805,14 @@ export class CanvasInteractions {
|
|||||||
// Rotate mouse delta into the layer's unrotated frame
|
// Rotate mouse delta into the layer's unrotated frame
|
||||||
const deltaX_world = mouseX_local - dragStartX_local;
|
const deltaX_world = mouseX_local - dragStartX_local;
|
||||||
const deltaY_world = mouseY_local - dragStartY_local;
|
const deltaY_world = mouseY_local - dragStartY_local;
|
||||||
const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
let mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
||||||
const mouseDeltaY_local = deltaY_world * cos - deltaX_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.
|
// Convert the on-screen mouse delta to an image-space delta.
|
||||||
const screenToImageScaleX = o.originalWidth / o.width;
|
const screenToImageScaleX = o.originalWidth / o.width;
|
||||||
const screenToImageScaleY = o.originalHeight / o.height;
|
const screenToImageScaleY = o.originalHeight / o.height;
|
||||||
@@ -741,19 +820,39 @@ export class CanvasInteractions {
|
|||||||
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
|
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
|
||||||
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
|
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
|
// Apply the image-space delta to the appropriate edges of the crop bounds
|
||||||
|
const isFlippedH = layer.flipH;
|
||||||
|
const isFlippedV = layer.flipV;
|
||||||
if (handle?.includes('w')) {
|
if (handle?.includes('w')) {
|
||||||
newCropBounds.x += delta_image_x;
|
if (isFlippedH)
|
||||||
newCropBounds.width -= delta_image_x;
|
newCropBounds.width += delta_image_x;
|
||||||
|
else {
|
||||||
|
newCropBounds.x += delta_image_x;
|
||||||
|
newCropBounds.width -= delta_image_x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (handle?.includes('e')) {
|
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')) {
|
if (handle?.includes('n')) {
|
||||||
newCropBounds.y += delta_image_y;
|
if (isFlippedV)
|
||||||
newCropBounds.height -= delta_image_y;
|
newCropBounds.height += delta_image_y;
|
||||||
|
else {
|
||||||
|
newCropBounds.y += delta_image_y;
|
||||||
|
newCropBounds.height -= delta_image_y;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (handle?.includes('s')) {
|
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 (newCropBounds.width < 1) {
|
||||||
@@ -820,7 +919,7 @@ export class CanvasInteractions {
|
|||||||
if (!layer)
|
if (!layer)
|
||||||
return;
|
return;
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined)
|
if (!o)
|
||||||
return;
|
return;
|
||||||
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
|
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);
|
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createModuleLogger } from "./utils/LoggerUtils.js";
|
|||||||
import { generateUUID, generateUniqueFileName, createCanvas } from "./utils/CommonUtils.js";
|
import { generateUUID, generateUniqueFileName, createCanvas } from "./utils/CommonUtils.js";
|
||||||
import { withErrorHandling, createValidationError } from "./ErrorHandler.js";
|
import { withErrorHandling, createValidationError } from "./ErrorHandler.js";
|
||||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||||
|
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -16,6 +17,17 @@ export class CanvasLayers {
|
|||||||
this.blendMenuElement = null;
|
this.blendMenuElement = null;
|
||||||
this.blendMenuWorldX = 0;
|
this.blendMenuWorldX = 0;
|
||||||
this.blendMenuWorldY = 0;
|
this.blendMenuWorldY = 0;
|
||||||
|
// Cache for processed images with effects applied
|
||||||
|
this.processedImageCache = new Map();
|
||||||
|
// Debouncing system for processed image creation
|
||||||
|
this.processedImageDebounceTimers = new Map();
|
||||||
|
this.PROCESSED_IMAGE_DEBOUNCE_DELAY = 1000; // 1 second
|
||||||
|
this.globalDebounceTimer = null;
|
||||||
|
this.lastRenderTime = 0;
|
||||||
|
this.layersAdjustingBlendArea = new Set();
|
||||||
|
this.layersTransformingCropBounds = new Set();
|
||||||
|
this.layersTransformingScale = new Set();
|
||||||
|
this.layersWheelScaling = new Set();
|
||||||
this.addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => {
|
this.addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => {
|
||||||
if (!image) {
|
if (!image) {
|
||||||
throw createValidationError("Image is required for layer creation");
|
throw createValidationError("Image is required for layer creation");
|
||||||
@@ -103,6 +115,8 @@ export class CanvasLayers {
|
|||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.clipboardManager = new ClipboardManager(canvas);
|
this.clipboardManager = new ClipboardManager(canvas);
|
||||||
this.distanceFieldCache = new WeakMap();
|
this.distanceFieldCache = new WeakMap();
|
||||||
|
this.processedImageCache = new Map();
|
||||||
|
this.processedImageDebounceTimers = new Map();
|
||||||
this.blendModes = [
|
this.blendModes = [
|
||||||
{ name: 'normal', label: 'Normal' },
|
{ name: 'normal', label: 'Normal' },
|
||||||
{ name: 'multiply', label: 'Multiply' },
|
{ name: 'multiply', label: 'Multiply' },
|
||||||
@@ -122,6 +136,8 @@ export class CanvasLayers {
|
|||||||
this.isAdjustingOpacity = false;
|
this.isAdjustingOpacity = false;
|
||||||
this.internalClipboard = [];
|
this.internalClipboard = [];
|
||||||
this.clipboardPreference = 'system';
|
this.clipboardPreference = 'system';
|
||||||
|
// Load CSS for blend mode menu
|
||||||
|
addStylesheet(getUrl('./css/blend_mode_menu.css'));
|
||||||
}
|
}
|
||||||
async copySelectedLayers() {
|
async copySelectedLayers() {
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length === 0)
|
if (this.canvas.canvasSelection.selectedLayers.length === 0)
|
||||||
@@ -310,7 +326,10 @@ export class CanvasLayers {
|
|||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
layer.width *= scale;
|
layer.width *= scale;
|
||||||
layer.height *= scale;
|
layer.height *= scale;
|
||||||
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.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
@@ -320,7 +339,6 @@ export class CanvasLayers {
|
|||||||
return;
|
return;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
layer.rotation += angle;
|
layer.rotation += angle;
|
||||||
this.invalidateBlendCache(layer);
|
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
@@ -366,31 +384,99 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingEnabled = true;
|
||||||
ctx.imageSmoothingQuality = 'high';
|
ctx.imageSmoothingQuality = 'high';
|
||||||
// Check if we need to apply blend area effect
|
|
||||||
const blendArea = layer.blendArea ?? 0;
|
const blendArea = layer.blendArea ?? 0;
|
||||||
const needsBlendAreaEffect = blendArea > 0;
|
const needsBlendAreaEffect = blendArea > 0;
|
||||||
if (needsBlendAreaEffect) {
|
// Check if we should render blend area live only in specific cases:
|
||||||
// Check if we have a valid cached blended image
|
// 1. When user is actively resizing in crop mode (transforming crop bounds) - only for the specific layer being transformed
|
||||||
if (layer.blendedImageCache && !layer.blendedImageDirty) {
|
// 2. When user is actively resizing in transform mode (scaling layer) - only for the specific layer being transformed
|
||||||
// Use cached blended image for optimal performance
|
// 3. When blend area slider is being adjusted - only for the layer that has the menu open
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
// 4. When layer is in the transforming crop bounds set (continues live rendering until cache is ready)
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
// 5. When layer is in the transforming scale set (continues live rendering until cache is ready)
|
||||||
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
const isTransformingCropBounds = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' &&
|
||||||
}
|
this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id &&
|
||||||
else {
|
layer.cropMode;
|
||||||
// Cache is invalid or doesn't exist, update it
|
// Check if user is actively scaling this layer in transform mode (not crop mode)
|
||||||
this.updateLayerBlendEffect(layer);
|
const isTransformingScale = this.canvas.canvasInteractions?.interaction?.mode === 'resizing' &&
|
||||||
// Use the newly created cache if available, otherwise fallback
|
this.canvas.canvasInteractions?.interaction?.transformingLayer?.id === layer.id &&
|
||||||
if (layer.blendedImageCache) {
|
!layer.cropMode;
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
// Check if this specific layer is the one being adjusted in blend area slider
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
const isThisLayerBeingAdjusted = this.layersAdjustingBlendArea.has(layer.id);
|
||||||
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
// 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);
|
||||||
else {
|
// Check if this layer is in the transforming scale set (continues live rendering until cache is ready)
|
||||||
// Fallback to normal drawing
|
const isTransformingScaleSet = this.layersTransformingScale.has(layer.id);
|
||||||
this._drawLayerImage(ctx, layer);
|
// 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 {
|
else {
|
||||||
// Normal drawing without blend area effect
|
// Normal drawing without blend area effect
|
||||||
@@ -398,14 +484,19 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
_drawLayerImage(ctx, layer) {
|
/**
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
* Zunifikowana funkcja do rysowania obrazu warstwy z crop
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
* @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
|
// 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 };
|
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
if (!layer.originalWidth || !layer.originalHeight) {
|
if (!layer.originalWidth || !layer.originalHeight) {
|
||||||
// Fallback for older layers without original dimensions or if data is missing
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
// Calculate the on-screen scale of the layer's transform frame
|
// 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
|
// Calculate the on-screen size of the cropped portion
|
||||||
const dWidth = s.width * layerScaleX;
|
const dWidth = s.width * layerScaleX;
|
||||||
const dHeight = s.height * layerScaleY;
|
const dHeight = s.height * layerScaleY;
|
||||||
// Calculate the on-screen position of the top-left of the cropped portion.
|
// 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 = offsetX + (s.x * layerScaleX);
|
||||||
const dX = (-layer.width / 2) + (s.x * layerScaleX);
|
const dY = offsetY + (s.y * layerScaleY);
|
||||||
const dY = (-layer.height / 2) + (s.y * layerScaleY);
|
|
||||||
ctx.drawImage(layer.image, s.x, s.y, s.width, s.height, // source rect (from original image)
|
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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/**
|
_drawLayerImage(ctx, layer) {
|
||||||
* Invalidates the blended image cache for a layer
|
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||||
*/
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
invalidateBlendCache(layer) {
|
this.drawLayerImageWithCrop(ctx, layer);
|
||||||
layer.blendedImageDirty = true;
|
|
||||||
layer.blendedImageCache = undefined;
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 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;
|
const blendArea = layer.blendArea ?? 0;
|
||||||
if (blendArea <= 0) {
|
if (layer.cropBounds && layer.originalWidth && layer.originalHeight) {
|
||||||
// No blend effect needed, clear cache
|
// Create a cropped canvas
|
||||||
layer.blendedImageCache = undefined;
|
const s = layer.cropBounds;
|
||||||
layer.blendedImageDirty = false;
|
const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height);
|
||||||
return;
|
if (cropCtx) {
|
||||||
}
|
cropCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, 0, 0, s.width, s.height);
|
||||||
try {
|
// Generate distance field mask for the cropped region
|
||||||
log.debug(`Updating blend effect cache for layer ${layer.id}, blendArea: ${blendArea}%`);
|
const maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea);
|
||||||
// Create the blended image using the same logic as _drawLayer
|
if (maskCanvas) {
|
||||||
let maskCanvas = null;
|
return {
|
||||||
let maskWidth = layer.width;
|
maskCanvas,
|
||||||
let maskHeight = layer.height;
|
maskWidth: s.width,
|
||||||
if (layer.cropBounds && layer.originalWidth && layer.originalHeight) {
|
maskHeight: s.height
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
}
|
||||||
// No crop, use full image
|
else {
|
||||||
maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
|
// No crop, use full image
|
||||||
maskWidth = layer.originalWidth || layer.width;
|
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
|
||||||
maskHeight = layer.originalHeight || layer.height;
|
|
||||||
}
|
|
||||||
if (maskCanvas) {
|
if (maskCanvas) {
|
||||||
// Create the final blended canvas
|
return {
|
||||||
const { canvas: blendedCanvas, ctx: blendedCtx } = createCanvas(layer.width, layer.height);
|
maskCanvas,
|
||||||
if (blendedCtx) {
|
maskWidth: layer.originalWidth || layer.width,
|
||||||
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
maskHeight: layer.originalHeight || layer.height
|
||||||
if (!layer.originalWidth || !layer.originalHeight) {
|
};
|
||||||
blendedCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
}
|
||||||
}
|
}
|
||||||
else {
|
return null;
|
||||||
const layerScaleX = layer.width / layer.originalWidth;
|
}
|
||||||
const layerScaleY = layer.height / layer.originalHeight;
|
/**
|
||||||
const dWidth = s.width * layerScaleX;
|
* Zunifikowana funkcja do rysowania warstwy z blend area na canvas
|
||||||
const dHeight = s.height * layerScaleY;
|
* @param ctx Canvas context
|
||||||
const dX = s.x * layerScaleX;
|
* @param layer Warstwa do narysowania
|
||||||
const dY = s.y * layerScaleY;
|
* @param offsetX Przesunięcie X (domyślnie -width/2)
|
||||||
blendedCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, dX, dY, dWidth, dHeight);
|
* @param offsetY Przesunięcie Y (domyślnie -height/2)
|
||||||
// Apply the distance field mask only to the visible (cropped) area
|
*/
|
||||||
blendedCtx.globalCompositeOperation = 'destination-in';
|
drawLayerWithBlendArea(ctx, layer, offsetX = -layer.width / 2, offsetY = -layer.height / 2) {
|
||||||
// Scale the mask to match the drawn area
|
const maskInfo = this.createBlendAreaMask(layer);
|
||||||
blendedCtx.drawImage(maskCanvas, 0, 0, maskWidth, maskHeight, dX, dY, dWidth, dHeight);
|
if (maskInfo) {
|
||||||
}
|
const { maskCanvas, maskWidth, maskHeight } = maskInfo;
|
||||||
// Store the blended result in cache
|
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
layer.blendedImageCache = blendedCanvas;
|
if (!layer.originalWidth || !layer.originalHeight) {
|
||||||
layer.blendedImageDirty = false;
|
// Fallback - just draw the image normally
|
||||||
log.debug(`Blend effect cache updated for layer ${layer.id}`);
|
ctx.drawImage(layer.image, offsetX, offsetY, layer.width, layer.height);
|
||||||
}
|
|
||||||
else {
|
|
||||||
log.warn(`Failed to create blended canvas context for layer ${layer.id}`);
|
|
||||||
layer.blendedImageCache = undefined;
|
|
||||||
layer.blendedImageDirty = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
log.warn(`Failed to create distance field mask for layer ${layer.id}`);
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
layer.blendedImageCache = undefined;
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
layer.blendedImageDirty = false;
|
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) {
|
else {
|
||||||
log.error(`Error updating blend effect for layer ${layer.id}:`, error);
|
// Fallback - just draw the image normally
|
||||||
layer.blendedImageCache = undefined;
|
this.drawLayerImageWithCrop(ctx, layer, offsetX, offsetY);
|
||||||
layer.blendedImageDirty = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 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) {
|
getDistanceFieldMaskSync(imageOrCanvas, blendArea) {
|
||||||
// Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references)
|
// Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references)
|
||||||
let cacheKey = imageOrCanvas;
|
let cacheKey = imageOrCanvas;
|
||||||
@@ -578,7 +946,6 @@ export class CanvasLayers {
|
|||||||
return;
|
return;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
layer.flipH = !layer.flipH;
|
layer.flipH = !layer.flipH;
|
||||||
this.invalidateBlendCache(layer);
|
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
@@ -588,24 +955,19 @@ export class CanvasLayers {
|
|||||||
return;
|
return;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
layer.flipV = !layer.flipV;
|
layer.flipV = !layer.flipV;
|
||||||
this.invalidateBlendCache(layer);
|
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
}
|
}
|
||||||
async getLayerImageData(layer) {
|
async getLayerImageData(layer) {
|
||||||
try {
|
try {
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height, '2d', { willReadFrequently: true });
|
const width = layer.originalWidth || layer.width;
|
||||||
|
const height = layer.originalHeight || layer.height;
|
||||||
|
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||||
if (!tempCtx)
|
if (!tempCtx)
|
||||||
throw new Error("Could not create canvas context");
|
throw new Error("Could not create canvas context");
|
||||||
// We need to draw the layer relative to the new canvas, so we "move" it to 0,0
|
// Use original image directly to ensure full quality
|
||||||
// by creating a temporary layer object for drawing.
|
tempCtx.drawImage(layer.image, 0, 0, width, height);
|
||||||
const layerToDraw = {
|
|
||||||
...layer,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
};
|
|
||||||
this._drawLayer(tempCtx, layerToDraw);
|
|
||||||
const dataUrl = tempCanvas.toDataURL('image/png');
|
const dataUrl = tempCanvas.toDataURL('image/png');
|
||||||
if (!dataUrl.startsWith('data:image/png;base64,')) {
|
if (!dataUrl.startsWith('data:image/png;base64,')) {
|
||||||
throw new Error("Invalid image data format");
|
throw new Error("Invalid image data format");
|
||||||
@@ -674,9 +1036,16 @@ export class CanvasLayers {
|
|||||||
const layerScaleY = layer.height / layer.originalHeight;
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
const cropRectW = layer.cropBounds.width * layerScaleX;
|
const cropRectW = layer.cropBounds.width * layerScaleX;
|
||||||
const cropRectH = layer.cropBounds.height * layerScaleY;
|
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
|
// 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 cropCenterX_local = (-layer.width / 2) + ((effectiveCropX + layer.cropBounds.width / 2) * layerScaleX);
|
||||||
const cropCenterY_local = (-layer.height / 2) + ((layer.cropBounds.y + layer.cropBounds.height / 2) * layerScaleY);
|
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
|
// Rotate this local center to find the world-space center of the crop rect
|
||||||
handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin);
|
handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin);
|
||||||
handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos);
|
handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos);
|
||||||
@@ -785,65 +1154,14 @@ export class CanvasLayers {
|
|||||||
const menu = document.createElement('div');
|
const menu = document.createElement('div');
|
||||||
this.blendMenuElement = menu;
|
this.blendMenuElement = menu;
|
||||||
menu.id = 'blend-mode-menu';
|
menu.id = 'blend-mode-menu';
|
||||||
menu.style.cssText = `
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: 1px solid #3a3a3a;
|
|
||||||
border-radius: 4px;
|
|
||||||
z-index: 10000;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
|
||||||
min-width: 200px;
|
|
||||||
`;
|
|
||||||
const titleBar = document.createElement('div');
|
const titleBar = document.createElement('div');
|
||||||
titleBar.style.cssText = `
|
titleBar.className = 'blend-menu-title-bar';
|
||||||
background: #3a3a3a;
|
|
||||||
color: white;
|
|
||||||
padding: 8px 10px;
|
|
||||||
cursor: move;
|
|
||||||
user-select: none;
|
|
||||||
border-radius: 3px 3px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
border-bottom: 1px solid #4a4a4a;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
`;
|
|
||||||
const titleText = document.createElement('span');
|
const titleText = document.createElement('span');
|
||||||
titleText.textContent = `Blend Mode: ${selectedLayer.name}`;
|
titleText.textContent = `Blend Mode: ${selectedLayer.name}`;
|
||||||
titleText.style.cssText = `
|
titleText.className = 'blend-menu-title-text';
|
||||||
flex: 1;
|
|
||||||
cursor: move;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
`;
|
|
||||||
const closeButton = document.createElement('button');
|
const closeButton = document.createElement('button');
|
||||||
closeButton.textContent = '×';
|
closeButton.textContent = '×';
|
||||||
closeButton.style.cssText = `
|
closeButton.className = 'blend-menu-close-button';
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
font-size: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
`;
|
|
||||||
closeButton.onmouseover = () => {
|
|
||||||
closeButton.style.backgroundColor = '#4a4a4a';
|
|
||||||
};
|
|
||||||
closeButton.onmouseout = () => {
|
|
||||||
closeButton.style.backgroundColor = 'transparent';
|
|
||||||
};
|
|
||||||
closeButton.onclick = (e) => {
|
closeButton.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.closeBlendModeMenu();
|
this.closeBlendModeMenu();
|
||||||
@@ -851,32 +1169,54 @@ export class CanvasLayers {
|
|||||||
titleBar.appendChild(titleText);
|
titleBar.appendChild(titleText);
|
||||||
titleBar.appendChild(closeButton);
|
titleBar.appendChild(closeButton);
|
||||||
const content = document.createElement('div');
|
const content = document.createElement('div');
|
||||||
content.style.cssText = `padding: 5px;`;
|
content.className = 'blend-menu-content';
|
||||||
menu.appendChild(titleBar);
|
menu.appendChild(titleBar);
|
||||||
menu.appendChild(content);
|
menu.appendChild(content);
|
||||||
const blendAreaContainer = document.createElement('div');
|
const blendAreaContainer = document.createElement('div');
|
||||||
blendAreaContainer.style.cssText = `padding: 5px 10px; border-bottom: 1px solid #4a4a4a;`;
|
blendAreaContainer.className = 'blend-area-container';
|
||||||
const blendAreaLabel = document.createElement('label');
|
const blendAreaLabel = document.createElement('label');
|
||||||
blendAreaLabel.textContent = 'Blend Area';
|
blendAreaLabel.textContent = 'Blend Area';
|
||||||
blendAreaLabel.style.color = 'white';
|
blendAreaLabel.className = 'blend-area-label';
|
||||||
const blendAreaSlider = document.createElement('input');
|
const blendAreaSlider = document.createElement('input');
|
||||||
blendAreaSlider.type = 'range';
|
blendAreaSlider.type = 'range';
|
||||||
blendAreaSlider.min = '0';
|
blendAreaSlider.min = '0';
|
||||||
blendAreaSlider.max = '100';
|
blendAreaSlider.max = '100';
|
||||||
|
blendAreaSlider.className = 'blend-area-slider';
|
||||||
blendAreaSlider.value = selectedLayer?.blendArea?.toString() ?? '0';
|
blendAreaSlider.value = selectedLayer?.blendArea?.toString() ?? '0';
|
||||||
blendAreaSlider.oninput = () => {
|
blendAreaSlider.oninput = () => {
|
||||||
if (selectedLayer) {
|
if (selectedLayer) {
|
||||||
const newValue = parseInt(blendAreaSlider.value, 10);
|
const newValue = parseInt(blendAreaSlider.value, 10);
|
||||||
selectedLayer.blendArea = newValue;
|
selectedLayer.blendArea = newValue;
|
||||||
// Invalidate cache when blend area changes
|
// Set flag to enable live blend area rendering for this specific layer
|
||||||
this.invalidateBlendCache(selectedLayer);
|
this.layersAdjustingBlendArea.add(selectedLayer.id);
|
||||||
|
// Invalidate processed image cache when blend area changes
|
||||||
|
this.invalidateProcessedImageCache(selectedLayer.id);
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
blendAreaSlider.addEventListener('change', () => {
|
blendAreaSlider.addEventListener('change', () => {
|
||||||
|
// When user stops adjusting, create cache asynchronously but keep live rendering until cache is ready
|
||||||
if (selectedLayer) {
|
if (selectedLayer) {
|
||||||
// Update the blend effect cache when the slider value is finalized
|
const layerId = selectedLayer.id;
|
||||||
this.updateLayerBlendEffect(selectedLayer);
|
const cacheKey = this.getProcessedImageCacheKey(selectedLayer);
|
||||||
|
// Create processed image asynchronously
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const processedImage = this.createProcessedImage(selectedLayer);
|
||||||
|
if (processedImage) {
|
||||||
|
this.processedImageCache.set(cacheKey, processedImage);
|
||||||
|
log.debug(`Cached processed image for layer ${layerId} after slider change`);
|
||||||
|
// Only now remove from live rendering set and trigger re-render
|
||||||
|
this.layersAdjustingBlendArea.delete(layerId);
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error('Failed to create processed image after slider change:', error);
|
||||||
|
// Fallback: remove from live rendering even if cache creation failed
|
||||||
|
this.layersAdjustingBlendArea.delete(layerId);
|
||||||
|
}
|
||||||
|
}, 0); // Use setTimeout to make it asynchronous
|
||||||
}
|
}
|
||||||
this.canvas.saveState();
|
this.canvas.saveState();
|
||||||
});
|
});
|
||||||
@@ -912,20 +1252,19 @@ export class CanvasLayers {
|
|||||||
this.blendModes.forEach((mode) => {
|
this.blendModes.forEach((mode) => {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'blend-mode-container';
|
container.className = 'blend-mode-container';
|
||||||
container.style.cssText = `margin-bottom: 5px;`;
|
|
||||||
const option = document.createElement('div');
|
const option = document.createElement('div');
|
||||||
option.style.cssText = `padding: 5px 10px; color: white; cursor: pointer; transition: background-color 0.2s;`;
|
option.className = 'blend-mode-option';
|
||||||
option.textContent = `${mode.label} (${mode.name})`;
|
option.textContent = `${mode.label} (${mode.name})`;
|
||||||
const slider = document.createElement('input');
|
const slider = document.createElement('input');
|
||||||
slider.type = 'range';
|
slider.type = 'range';
|
||||||
slider.min = '0';
|
slider.min = '0';
|
||||||
slider.max = '100';
|
slider.max = '100';
|
||||||
|
slider.className = 'blend-opacity-slider';
|
||||||
const selectedLayer = this.canvas.canvasSelection.selectedLayers[0];
|
const selectedLayer = this.canvas.canvasSelection.selectedLayers[0];
|
||||||
slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100';
|
slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100';
|
||||||
slider.style.cssText = `width: 100%; margin: 5px 0; display: none;`;
|
|
||||||
if (selectedLayer && selectedLayer.blendMode === mode.name) {
|
if (selectedLayer && selectedLayer.blendMode === mode.name) {
|
||||||
slider.style.display = 'block';
|
container.classList.add('active');
|
||||||
option.style.backgroundColor = '#3a3a3a';
|
option.classList.add('active');
|
||||||
}
|
}
|
||||||
option.onclick = () => {
|
option.onclick = () => {
|
||||||
// Re-check selected layer at the time of click
|
// Re-check selected layer at the time of click
|
||||||
@@ -933,19 +1272,17 @@ export class CanvasLayers {
|
|||||||
if (!currentSelectedLayer) {
|
if (!currentSelectedLayer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Hide only the opacity sliders within other blend mode containers
|
// Remove active class from all containers and options
|
||||||
content.querySelectorAll('.blend-mode-container').forEach(c => {
|
content.querySelectorAll('.blend-mode-container').forEach(c => {
|
||||||
const opacitySlider = c.querySelector('input[type="range"]');
|
c.classList.remove('active');
|
||||||
if (opacitySlider) {
|
const optionDiv = c.querySelector('.blend-mode-option');
|
||||||
opacitySlider.style.display = 'none';
|
|
||||||
}
|
|
||||||
const optionDiv = c.querySelector('div');
|
|
||||||
if (optionDiv) {
|
if (optionDiv) {
|
||||||
optionDiv.style.backgroundColor = '';
|
optionDiv.classList.remove('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
slider.style.display = 'block';
|
// Add active class to current container and option
|
||||||
option.style.backgroundColor = '#3a3a3a';
|
container.classList.add('active');
|
||||||
|
option.classList.add('active');
|
||||||
currentSelectedLayer.blendMode = mode.name;
|
currentSelectedLayer.blendMode = mode.name;
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||||
import { createCanvas } from "./utils/CommonUtils.js";
|
import { createCanvas } from "./utils/CommonUtils.js";
|
||||||
|
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||||
const log = createModuleLogger('CanvasLayersPanel');
|
const log = createModuleLogger('CanvasLayersPanel');
|
||||||
export class CanvasLayersPanel {
|
export class CanvasLayersPanel {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
@@ -18,6 +19,8 @@ export class CanvasLayersPanel {
|
|||||||
this.handleDrop = this.handleDrop.bind(this);
|
this.handleDrop = this.handleDrop.bind(this);
|
||||||
// Preload icons
|
// Preload icons
|
||||||
this.initializeIcons();
|
this.initializeIcons();
|
||||||
|
// Load CSS for layers panel
|
||||||
|
addStylesheet(getUrl('./css/layers_panel.css'));
|
||||||
log.info('CanvasLayersPanel initialized');
|
log.info('CanvasLayersPanel initialized');
|
||||||
}
|
}
|
||||||
async initializeIcons() {
|
async initializeIcons() {
|
||||||
@@ -31,22 +34,15 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
createIconElement(toolName, size = 16) {
|
createIconElement(toolName, size = 16) {
|
||||||
const iconContainer = document.createElement('div');
|
const iconContainer = document.createElement('div');
|
||||||
iconContainer.style.cssText = `
|
iconContainer.className = 'icon-container';
|
||||||
width: ${size}px;
|
iconContainer.style.width = `${size}px`;
|
||||||
height: ${size}px;
|
iconContainer.style.height = `${size}px`;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
const icon = iconLoader.getIcon(toolName);
|
const icon = iconLoader.getIcon(toolName);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
if (icon instanceof HTMLImageElement) {
|
if (icon instanceof HTMLImageElement) {
|
||||||
const img = icon.cloneNode();
|
const img = icon.cloneNode();
|
||||||
img.style.cssText = `
|
img.style.width = `${size}px`;
|
||||||
width: ${size}px;
|
img.style.height = `${size}px`;
|
||||||
height: ${size}px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
`;
|
|
||||||
iconContainer.appendChild(img);
|
iconContainer.appendChild(img);
|
||||||
}
|
}
|
||||||
else if (icon instanceof HTMLCanvasElement) {
|
else if (icon instanceof HTMLCanvasElement) {
|
||||||
@@ -59,9 +55,9 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback text
|
// Fallback text
|
||||||
|
iconContainer.classList.add('fallback-text');
|
||||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||||
iconContainer.style.color = '#ffffff';
|
|
||||||
}
|
}
|
||||||
return iconContainer;
|
return iconContainer;
|
||||||
}
|
}
|
||||||
@@ -72,24 +68,15 @@ export class CanvasLayersPanel {
|
|||||||
else {
|
else {
|
||||||
// Create a "hidden" version of the visibility icon
|
// Create a "hidden" version of the visibility icon
|
||||||
const iconContainer = document.createElement('div');
|
const iconContainer = document.createElement('div');
|
||||||
iconContainer.style.cssText = `
|
iconContainer.className = 'icon-container visibility-hidden';
|
||||||
width: 16px;
|
iconContainer.style.width = '16px';
|
||||||
height: 16px;
|
iconContainer.style.height = '16px';
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0.5;
|
|
||||||
`;
|
|
||||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
if (icon instanceof HTMLImageElement) {
|
if (icon instanceof HTMLImageElement) {
|
||||||
const img = icon.cloneNode();
|
const img = icon.cloneNode();
|
||||||
img.style.cssText = `
|
img.style.width = '16px';
|
||||||
width: 16px;
|
img.style.height = '16px';
|
||||||
height: 16px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
opacity: 0.3;
|
|
||||||
`;
|
|
||||||
iconContainer.appendChild(img);
|
iconContainer.appendChild(img);
|
||||||
}
|
}
|
||||||
else if (icon instanceof HTMLCanvasElement) {
|
else if (icon instanceof HTMLCanvasElement) {
|
||||||
@@ -103,9 +90,9 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback
|
// Fallback
|
||||||
|
iconContainer.classList.add('fallback-text');
|
||||||
iconContainer.textContent = 'H';
|
iconContainer.textContent = 'H';
|
||||||
iconContainer.style.fontSize = '10px';
|
iconContainer.style.fontSize = '10px';
|
||||||
iconContainer.style.color = '#888888';
|
|
||||||
}
|
}
|
||||||
return iconContainer;
|
return iconContainer;
|
||||||
}
|
}
|
||||||
@@ -126,7 +113,6 @@ export class CanvasLayersPanel {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
this.layersContainer = this.container.querySelector('#layers-container');
|
this.layersContainer = this.container.querySelector('#layers-container');
|
||||||
this.injectStyles();
|
|
||||||
// Setup event listeners dla przycisków
|
// Setup event listeners dla przycisków
|
||||||
this.setupControlButtons();
|
this.setupControlButtons();
|
||||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||||
@@ -140,212 +126,6 @@ export class CanvasLayersPanel {
|
|||||||
log.debug('Panel structure created');
|
log.debug('Panel structure created');
|
||||||
return this.container;
|
return this.container;
|
||||||
}
|
}
|
||||||
injectStyles() {
|
|
||||||
const styleId = 'layers-panel-styles';
|
|
||||||
if (document.getElementById(styleId)) {
|
|
||||||
return; // Style już istnieją
|
|
||||||
}
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = styleId;
|
|
||||||
style.textContent = `
|
|
||||||
.layers-panel {
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: 1px solid #3a3a3a;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #ffffff;
|
|
||||||
user-select: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #3a3a3a;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-title {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn {
|
|
||||||
background: #3a3a3a;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn:hover {
|
|
||||||
background: #4a4a4a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn:active {
|
|
||||||
background: #5a5a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 4px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
position: relative;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row.selected {
|
|
||||||
background: #2d5aa0 !important;
|
|
||||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row.dragging {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.layer-thumbnail {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: transparent;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
|
||||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
|
||||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
|
||||||
background-size: 8px 8px;
|
|
||||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail canvas {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name.editing {
|
|
||||||
background: #4a4a4a;
|
|
||||||
border: 1px solid #6a6a6a;
|
|
||||||
outline: none;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name input {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 12px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-insertion-line {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: #4a7bc8;
|
|
||||||
border-radius: 1px;
|
|
||||||
z-index: 1000;
|
|
||||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-track {
|
|
||||||
background: #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-thumb {
|
|
||||||
background: #4a4a4a;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #5a5a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-visibility-toggle {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-visibility-toggle:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
log.debug('Styles injected');
|
|
||||||
}
|
|
||||||
setupControlButtons() {
|
setupControlButtons() {
|
||||||
if (!this.container)
|
if (!this.container)
|
||||||
return;
|
return;
|
||||||
@@ -359,6 +139,8 @@ export class CanvasLayersPanel {
|
|||||||
log.info('Delete layer button clicked');
|
log.info('Delete layer button clicked');
|
||||||
this.deleteSelectedLayers();
|
this.deleteSelectedLayers();
|
||||||
});
|
});
|
||||||
|
// Initial button state update
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
renderLayers() {
|
renderLayers() {
|
||||||
if (!this.layersContainer) {
|
if (!this.layersContainer) {
|
||||||
@@ -448,6 +230,7 @@ export class CanvasLayersPanel {
|
|||||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
||||||
this.canvas.updateSelection(newSelection);
|
this.canvas.updateSelection(newSelection);
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
layerRow.addEventListener('dblclick', (e) => {
|
layerRow.addEventListener('dblclick', (e) => {
|
||||||
@@ -480,6 +263,7 @@ export class CanvasLayersPanel {
|
|||||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||||
}
|
}
|
||||||
startEditingLayerName(nameElement, layer) {
|
startEditingLayerName(nameElement, layer) {
|
||||||
@@ -660,12 +444,29 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
|
||||||
|
*/
|
||||||
|
updateButtonStates() {
|
||||||
|
if (!this.container)
|
||||||
|
return;
|
||||||
|
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||||
|
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.disabled = !hasSelectedLayers;
|
||||||
|
deleteBtn.title = hasSelectedLayers
|
||||||
|
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
|
||||||
|
: 'No layers selected';
|
||||||
|
}
|
||||||
|
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||||
*/
|
*/
|
||||||
onSelectionChanged() {
|
onSelectionChanged() {
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.container && this.container.parentNode) {
|
if (this.container && this.container.parentNode) {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export class CanvasRenderer {
|
|||||||
this.lastRenderTime = 0;
|
this.lastRenderTime = 0;
|
||||||
this.renderInterval = 1000 / 60;
|
this.renderInterval = 1000 / 60;
|
||||||
this.isDirty = false;
|
this.isDirty = false;
|
||||||
|
// Initialize overlay canvases
|
||||||
|
this.initOverlay();
|
||||||
|
this.initStrokeOverlay();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Helper function to draw text with background at world coordinates
|
* Helper function to draw text with background at world coordinates
|
||||||
@@ -102,10 +105,12 @@ export class CanvasRenderer {
|
|||||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.canvas.maskTool.isActive) {
|
||||||
|
// In draw mask mode, use the previewOpacity value from the slider
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
// When not in draw mask mode, show mask at full opacity
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 1.0;
|
ctx.globalAlpha = 1.0;
|
||||||
}
|
}
|
||||||
@@ -158,6 +163,11 @@ export class CanvasRenderer {
|
|||||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||||
}
|
}
|
||||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
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
|
// Update Batch Preview UI positions
|
||||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
this.canvas.batchPreviewManagers.forEach((manager) => {
|
this.canvas.batchPreviewManagers.forEach((manager) => {
|
||||||
@@ -467,8 +477,10 @@ export class CanvasRenderer {
|
|||||||
// Draw line to rotation handle
|
// Draw line to rotation handle
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, -halfH);
|
const startY = layer.flipV ? halfH : -halfH;
|
||||||
ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom);
|
const endY = startY + (layer.flipV ? 1 : -1) * (20 / this.canvas.viewport.zoom);
|
||||||
|
ctx.moveTo(0, startY);
|
||||||
|
ctx.lineTo(0, endY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
// --- DRAW HANDLES (Unified Logic) ---
|
// --- DRAW HANDLES (Unified Logic) ---
|
||||||
@@ -476,19 +488,29 @@ export class CanvasRenderer {
|
|||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.strokeStyle = '#000000';
|
ctx.strokeStyle = '#000000';
|
||||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
|
const centerX = layer.x + layer.width / 2;
|
||||||
|
const centerY = layer.y + layer.height / 2;
|
||||||
for (const key in handles) {
|
for (const key in handles) {
|
||||||
// Skip rotation handle in crop mode
|
// Skip rotation handle in crop mode
|
||||||
if (layer.cropMode && key === 'rot')
|
if (layer.cropMode && key === 'rot')
|
||||||
continue;
|
continue;
|
||||||
const point = handles[key];
|
const point = handles[key];
|
||||||
// The handle position is already in world space, we need it in the layer's rotated space
|
// The handle position is already in world space.
|
||||||
const localX = point.x - (layer.x + layer.width / 2);
|
// We need to convert it to the layer's local, un-rotated space.
|
||||||
const localY = point.y - (layer.y + layer.height / 2);
|
const dx = point.x - centerX;
|
||||||
|
const dy = point.y - centerY;
|
||||||
|
// "Un-rotate" the position to get it in the layer's local, un-rotated space
|
||||||
const rad = -layer.rotation * Math.PI / 180;
|
const rad = -layer.rotation * Math.PI / 180;
|
||||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
const cos = Math.cos(rad);
|
||||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
const sin = Math.sin(rad);
|
||||||
|
const localX = dx * cos - dy * sin;
|
||||||
|
const localY = dx * sin + dy * cos;
|
||||||
|
// 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.beginPath();
|
||||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
@@ -571,4 +593,243 @@ export class CanvasRenderer {
|
|||||||
padding: 8
|
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 = '0px';
|
||||||
|
this.strokeOverlayCanvas.style.top = '0px';
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
|
||||||
const newLayer = { ...layer, imageId: layer.imageId || '' };
|
const newLayer = { ...layer, imageId: layer.imageId || '' };
|
||||||
delete newLayer.image;
|
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.image instanceof HTMLImageElement) {
|
||||||
if (layer.imageId) {
|
if (layer.imageId) {
|
||||||
newLayer.imageId = layer.imageId;
|
newLayer.imageId = layer.imageId;
|
||||||
@@ -407,12 +404,10 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
}
|
}
|
||||||
if (this.maskUndoStack.length > 0) {
|
if (this.maskUndoStack.length > 0) {
|
||||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
|
||||||
if (maskCtx) {
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
maskCtx.drawImage(prevState, 0, 0);
|
|
||||||
}
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
@@ -423,12 +418,10 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
const nextState = this.maskRedoStack.pop();
|
const nextState = this.maskRedoStack.pop();
|
||||||
if (nextState) {
|
if (nextState) {
|
||||||
this.maskUndoStack.push(nextState);
|
this.maskUndoStack.push(nextState);
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
|
||||||
if (maskCtx) {
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
maskCtx.drawImage(nextState, 0, 0);
|
|
||||||
}
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
|
|||||||
198
js/CanvasView.js
198
js/CanvasView.js
@@ -98,7 +98,6 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
}),
|
}),
|
||||||
$el("button.painter-button.icon-button", {
|
$el("button.painter-button.icon-button", {
|
||||||
textContent: "?",
|
textContent: "?",
|
||||||
title: "Show shortcuts",
|
|
||||||
onmouseenter: (e) => {
|
onmouseenter: (e) => {
|
||||||
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
||||||
showTooltip(e.target, content);
|
showTooltip(e.target, content);
|
||||||
@@ -177,10 +176,22 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("span.switch-icon")
|
$el("span.switch-icon")
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
|
// Helper function to get current tooltip content based on switch state
|
||||||
|
const getCurrentTooltipContent = () => {
|
||||||
|
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
|
||||||
|
return checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
||||||
|
};
|
||||||
|
// Helper function to update tooltip content if it's currently visible
|
||||||
|
const updateTooltipIfVisible = () => {
|
||||||
|
// Only update if tooltip is currently visible
|
||||||
|
if (helpTooltip.style.display === 'block') {
|
||||||
|
const tooltipContent = getCurrentTooltipContent();
|
||||||
|
showTooltip(switchEl, tooltipContent);
|
||||||
|
}
|
||||||
|
};
|
||||||
// Tooltip logic
|
// Tooltip logic
|
||||||
switchEl.addEventListener("mouseenter", (e) => {
|
switchEl.addEventListener("mouseenter", (e) => {
|
||||||
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
|
const tooltipContent = getCurrentTooltipContent();
|
||||||
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
|
||||||
showTooltip(switchEl, tooltipContent);
|
showTooltip(switchEl, tooltipContent);
|
||||||
});
|
});
|
||||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||||
@@ -189,6 +200,8 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
|
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
|
||||||
input.addEventListener('change', () => {
|
input.addEventListener('change', () => {
|
||||||
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
|
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
|
||||||
|
// Update tooltip content immediately after state change
|
||||||
|
updateTooltipIfVisible();
|
||||||
});
|
});
|
||||||
// Initial state
|
// Initial state
|
||||||
iconLoader.preloadToolIcons().then(() => {
|
iconLoader.preloadToolIcons().then(() => {
|
||||||
@@ -417,6 +430,8 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
delete newLayer.imageId;
|
delete newLayer.imageId;
|
||||||
canvas.layers[selectedLayerIndex] = newLayer;
|
canvas.layers[selectedLayerIndex] = newLayer;
|
||||||
canvas.canvasSelection.updateSelection([newLayer]);
|
canvas.canvasSelection.updateSelection([newLayer]);
|
||||||
|
// Invalidate processed image cache when layer image changes (matting)
|
||||||
|
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
|
||||||
canvas.render();
|
canvas.render();
|
||||||
canvas.saveState();
|
canvas.saveState();
|
||||||
showSuccessNotification("Background removed successfully!");
|
showSuccessNotification("Background removed successfully!");
|
||||||
@@ -453,7 +468,8 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("div.painter-button-group", { id: "mask-controls" }, [
|
$el("div.painter-button-group", { id: "mask-controls" }, [
|
||||||
$el("label.clipboard-switch.mask-switch", {
|
$el("label.clipboard-switch.mask-switch", {
|
||||||
id: `toggle-mask-switch-${node.id}`,
|
id: `toggle-mask-switch-${node.id}`,
|
||||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
|
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" },
|
||||||
|
title: "Toggle mask overlay visibility on canvas (mask still affects output when disabled)"
|
||||||
}, [
|
}, [
|
||||||
$el("input", {
|
$el("input", {
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
@@ -538,6 +554,25 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
setTimeout(() => canvas.render(), 0);
|
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("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
||||||
$el("label", { for: "brush-size-slider", textContent: "Size:" }),
|
$el("label", { for: "brush-size-slider", textContent: "Size:" }),
|
||||||
$el("input", {
|
$el("input", {
|
||||||
@@ -876,7 +911,9 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
height: "100%"
|
height: "100%"
|
||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
||||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
if (node.addDOMWidget) {
|
||||||
|
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
|
}
|
||||||
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`);
|
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`);
|
||||||
let backdrop = null;
|
let backdrop = null;
|
||||||
let originalParent = null;
|
let originalParent = null;
|
||||||
@@ -965,7 +1002,11 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (!window.canvasExecutionStates) {
|
if (!window.canvasExecutionStates) {
|
||||||
window.canvasExecutionStates = new Map();
|
window.canvasExecutionStates = new Map();
|
||||||
}
|
}
|
||||||
node.canvasWidget = canvas;
|
// Store the entire widget object, not just the canvas
|
||||||
|
node.canvasWidget = {
|
||||||
|
canvas: canvas,
|
||||||
|
panel: controlPanel
|
||||||
|
};
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canvas.loadInitialState();
|
canvas.loadInitialState();
|
||||||
if (canvas.canvasLayersPanel) {
|
if (canvas.canvasLayersPanel) {
|
||||||
@@ -982,7 +1023,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (canvas && canvas.setPreviewVisibility) {
|
if (canvas && canvas.setPreviewVisibility) {
|
||||||
canvas.setPreviewVisibility(value);
|
canvas.setPreviewVisibility(value);
|
||||||
}
|
}
|
||||||
if (node.graph && node.graph.canvas) {
|
if (node.graph && node.graph.canvas && node.setDirtyCanvas) {
|
||||||
node.setDirtyCanvas(true, true);
|
node.setDirtyCanvas(true, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -998,7 +1039,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
}
|
}
|
||||||
const canvasNodeInstances = new Map();
|
const canvasNodeInstances = new Map();
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "Comfy.CanvasNode",
|
name: "Comfy.LayerForgeNode",
|
||||||
init() {
|
init() {
|
||||||
addStylesheet(getUrl('./css/canvas_view.css'));
|
addStylesheet(getUrl('./css/canvas_view.css'));
|
||||||
const originalQueuePrompt = app.queuePrompt;
|
const originalQueuePrompt = app.queuePrompt;
|
||||||
@@ -1032,7 +1073,7 @@ app.registerExtension({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
if (nodeType.comfyClass === "CanvasNode") {
|
if (nodeType.comfyClass === "LayerForgeNode") {
|
||||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
nodeType.prototype.onNodeCreated = function () {
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||||
@@ -1061,9 +1102,144 @@ app.registerExtension({
|
|||||||
const canvasWidget = await createCanvasWidget(this, null, app);
|
const canvasWidget = await createCanvasWidget(this, null, app);
|
||||||
canvasNodeInstances.set(this.id, canvasWidget);
|
canvasNodeInstances.set(this.id, canvasWidget);
|
||||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||||
|
// Store the canvas widget on the node
|
||||||
|
this.canvasWidget = canvasWidget;
|
||||||
|
// Check if there are already connected inputs
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setDirtyCanvas(true, true);
|
if (this.inputs && this.inputs.length > 0) {
|
||||||
}, 100);
|
// Check if input_image (index 0) is connected
|
||||||
|
if (this.inputs[0] && this.inputs[0].link) {
|
||||||
|
log.info("Input image already connected on node creation, checking for data...");
|
||||||
|
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||||
|
canvasWidget.canvas.inputDataLoaded = false;
|
||||||
|
// Only allow images on init; mask should load only on mask connect or execution
|
||||||
|
canvasWidget.canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "init_image_connected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.setDirtyCanvas) {
|
||||||
|
this.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
// Add onConnectionsChange handler to detect when inputs are connected
|
||||||
|
nodeType.prototype.onConnectionsChange = function (type, index, connected, link_info) {
|
||||||
|
log.info(`onConnectionsChange called: type=${type}, index=${index}, connected=${connected}`, link_info);
|
||||||
|
// Check if this is an input connection (type 1 = INPUT)
|
||||||
|
if (type === 1) {
|
||||||
|
// Get the canvas widget - it might be in different places
|
||||||
|
const canvasWidget = this.canvasWidget;
|
||||||
|
const canvas = canvasWidget?.canvas || canvasWidget;
|
||||||
|
if (!canvas || !canvas.canvasIO) {
|
||||||
|
log.warn("Canvas not ready in onConnectionsChange, scheduling retry...");
|
||||||
|
// Retry multiple times with increasing delays
|
||||||
|
const retryDelays = [500, 1000, 2000];
|
||||||
|
let retryCount = 0;
|
||||||
|
const tryAgain = () => {
|
||||||
|
const retryCanvas = this.canvasWidget?.canvas || this.canvasWidget;
|
||||||
|
if (retryCanvas && retryCanvas.canvasIO) {
|
||||||
|
log.info("Canvas now ready, checking for input data...");
|
||||||
|
if (connected) {
|
||||||
|
retryCanvas.inputDataLoaded = false;
|
||||||
|
// Respect which input triggered the connection:
|
||||||
|
const opts = (index === 1)
|
||||||
|
? { allowImage: false, allowMask: true, reason: "mask_connect" }
|
||||||
|
: { allowImage: true, allowMask: false, reason: "image_connect" };
|
||||||
|
retryCanvas.canvasIO.checkForInputData(opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (retryCount < retryDelays.length) {
|
||||||
|
log.warn(`Canvas still not ready, retry ${retryCount + 1}/${retryDelays.length}...`);
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.error("Canvas failed to initialize after multiple retries");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Handle input_image connection (index 0)
|
||||||
|
if (index === 0) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input image connected, marking for data check...");
|
||||||
|
// Reset the input data loaded flag to allow loading the new connection
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
// Also reset the last loaded image source and link ID to allow the new image
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
// Mark that we have a pending input connection
|
||||||
|
canvas.hasPendingInputConnection = true;
|
||||||
|
// If mask input is not connected and a mask was auto-applied from input_mask before, clear it now
|
||||||
|
if (!(this.inputs && this.inputs[1] && this.inputs[1].link)) {
|
||||||
|
if (canvas.maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
canvas.maskTool.clear();
|
||||||
|
canvas.render();
|
||||||
|
canvas.maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask because input_image connected without input_mask");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after connection...");
|
||||||
|
// Only load images here; masks should not auto-load on image connect
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "image_connect" });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("Input image disconnected");
|
||||||
|
canvas.hasPendingInputConnection = false;
|
||||||
|
// Reset when disconnected so a new connection can load
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle input_mask connection (index 1)
|
||||||
|
if (index === 1) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input mask connected");
|
||||||
|
// DON'T clear existing mask when connecting a new input
|
||||||
|
// Reset the loaded mask link ID to allow loading from the new connection
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
// Mark that we have a pending mask connection
|
||||||
|
canvas.hasPendingMaskConnection = true;
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after mask connection...");
|
||||||
|
// Only load mask here if it's immediately available from the connected node
|
||||||
|
// Don't load stale masks from backend storage
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: false, allowMask: true, reason: "mask_connect" });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("Input mask disconnected");
|
||||||
|
canvas.hasPendingMaskConnection = false;
|
||||||
|
// If the current mask came from input_mask, clear it to avoid affecting images when mask is not connected
|
||||||
|
if (canvas.maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
canvas.maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask due to mask input disconnection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Add onExecuted handler to check for input data after workflow execution
|
||||||
|
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||||
|
nodeType.prototype.onExecuted = function (message) {
|
||||||
|
log.info("Node executed, checking for input data...");
|
||||||
|
const canvas = this.canvasWidget?.canvas || this.canvasWidget;
|
||||||
|
if (canvas && canvas.canvasIO) {
|
||||||
|
// Don't reset inputDataLoaded - just check for new data
|
||||||
|
// On execution we allow both image and mask to load
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: "execution" });
|
||||||
|
}
|
||||||
|
// Call original if it exists
|
||||||
|
if (originalOnExecuted) {
|
||||||
|
originalOnExecuted.apply(this, arguments);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const onRemoved = nodeType.prototype.onRemoved;
|
const onRemoved = nodeType.prototype.onRemoved;
|
||||||
nodeType.prototype.onRemoved = function () {
|
nodeType.prototype.onRemoved = function () {
|
||||||
|
|||||||
@@ -424,7 +424,6 @@ export class MaskEditorIntegration {
|
|||||||
boundsPos: { x: bounds.x, y: bounds.y },
|
boundsPos: { x: bounds.x, y: bounds.y },
|
||||||
maskSize: { width: bounds.width, height: bounds.height }
|
maskSize: { width: bounds.width, height: bounds.height }
|
||||||
});
|
});
|
||||||
// Use the chunk system instead of direct canvas manipulation
|
|
||||||
this.maskTool.setMask(maskAsImage);
|
this.maskTool.setMask(maskAsImage);
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
await updateNodePreview(this.canvas, this.node, true);
|
await updateNodePreview(this.canvas, this.node, true);
|
||||||
|
|||||||
244
js/MaskTool.js
244
js/MaskTool.js
@@ -3,11 +3,15 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
|||||||
const log = createModuleLogger('Mask_tool');
|
const log = createModuleLogger('Mask_tool');
|
||||||
export class MaskTool {
|
export class MaskTool {
|
||||||
constructor(canvasInstance, callbacks = {}) {
|
constructor(canvasInstance, callbacks = {}) {
|
||||||
|
// Track strokes during drawing for efficient overlay updates
|
||||||
|
this.currentStrokePoints = [];
|
||||||
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
|
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
|
||||||
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
|
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
|
||||||
this.canvasInstance = canvasInstance;
|
this.canvasInstance = canvasInstance;
|
||||||
this.mainCanvas = canvasInstance.canvas;
|
this.mainCanvas = canvasInstance.canvas;
|
||||||
this.onStateChange = callbacks.onStateChange || null;
|
this.onStateChange = callbacks.onStateChange || null;
|
||||||
|
// Initialize stroke tracking for overlay drawing
|
||||||
|
this.currentStrokePoints = [];
|
||||||
// Initialize chunked mask system
|
// Initialize chunked mask system
|
||||||
this.maskChunks = new Map();
|
this.maskChunks = new Map();
|
||||||
this.chunkSize = 512;
|
this.chunkSize = 512;
|
||||||
@@ -28,8 +32,9 @@ export class MaskTool {
|
|||||||
this.isOverlayVisible = true;
|
this.isOverlayVisible = true;
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
this.brushSize = 20;
|
this.brushSize = 20;
|
||||||
this.brushStrength = 0.5;
|
this._brushStrength = 0.5;
|
||||||
this.brushHardness = 0.5;
|
this._brushHardness = 0.5;
|
||||||
|
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
|
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);
|
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) {
|
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() {
|
initMaskCanvas() {
|
||||||
// Initialize chunked system
|
// Initialize chunked system
|
||||||
@@ -671,16 +695,17 @@ export class MaskTool {
|
|||||||
this.brushSize = Math.max(1, size);
|
this.brushSize = Math.max(1, size);
|
||||||
}
|
}
|
||||||
setBrushStrength(strength) {
|
setBrushStrength(strength) {
|
||||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
this._brushStrength = Math.max(0, Math.min(1, strength));
|
||||||
}
|
}
|
||||||
handleMouseDown(worldCoords, viewCoords) {
|
handleMouseDown(worldCoords, viewCoords) {
|
||||||
if (!this.isActive)
|
if (!this.isActive)
|
||||||
return;
|
return;
|
||||||
this.isDrawing = true;
|
this.isDrawing = true;
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
// Activate chunks around the drawing position for performance
|
// Initialize stroke tracking for live preview
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints = [worldCoords];
|
||||||
this.draw(worldCoords);
|
// Clear any previous stroke overlay
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
}
|
}
|
||||||
handleMouseMove(worldCoords, viewCoords) {
|
handleMouseMove(worldCoords, viewCoords) {
|
||||||
@@ -689,14 +714,69 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
if (!this.isActive || !this.isDrawing)
|
if (!this.isActive || !this.isDrawing)
|
||||||
return;
|
return;
|
||||||
// Dynamically update active chunks as user moves while drawing
|
// Add point to stroke tracking
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints.push(worldCoords);
|
||||||
this.draw(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;
|
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() {
|
handleMouseLeave() {
|
||||||
this.previewVisible = false;
|
this.previewVisible = false;
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
|
// Clear overlay canvases when mouse leaves
|
||||||
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
}
|
}
|
||||||
handleMouseEnter() {
|
handleMouseEnter() {
|
||||||
this.previewVisible = true;
|
this.previewVisible = true;
|
||||||
@@ -706,10 +786,15 @@ export class MaskTool {
|
|||||||
return;
|
return;
|
||||||
if (this.isDrawing) {
|
if (this.isDrawing) {
|
||||||
this.isDrawing = false;
|
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.lastPosition = null;
|
||||||
this.currentDrawingChunk = null;
|
this.currentDrawingChunk = null;
|
||||||
// After drawing is complete, update active canvas to show all chunks
|
// 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.completeMaskOperation();
|
||||||
this.drawBrushPreview(viewCoords);
|
this.drawBrushPreview(viewCoords);
|
||||||
}
|
}
|
||||||
@@ -724,6 +809,38 @@ export class MaskTool {
|
|||||||
// This prevents unnecessary recomposition during drawing
|
// This prevents unnecessary recomposition during drawing
|
||||||
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
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
|
* 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.moveTo(startLocal.x, startLocal.y);
|
||||||
chunk.ctx.lineTo(endLocal.x, endLocal.y);
|
chunk.ctx.lineTo(endLocal.x, endLocal.y);
|
||||||
const gradientRadius = this.brushSize / 2;
|
const gradientRadius = this.brushSize / 2;
|
||||||
if (this.brushHardness === 1) {
|
if (this._brushHardness === 1) {
|
||||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||||
}
|
}
|
||||||
else {
|
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);
|
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)`);
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
chunk.ctx.strokeStyle = gradient;
|
chunk.ctx.strokeStyle = gradient;
|
||||||
}
|
}
|
||||||
@@ -805,28 +922,17 @@ export class MaskTool {
|
|||||||
return true; // For now, always draw - more precise intersection can be added later
|
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
|
* Updates active canvas when drawing affects chunks
|
||||||
* During drawing, only updates the affected active chunks for performance
|
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||||
*/
|
*/
|
||||||
updateActiveCanvasIfNeeded(startWorld, endWorld) {
|
updateActiveCanvasIfNeeded(startWorld, endWorld) {
|
||||||
// Calculate which chunks were affected by this drawing operation
|
// This method is now simplified - we only update after drawing is complete
|
||||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
if (!this.isDrawing) {
|
||||||
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 {
|
|
||||||
// Not drawing - do full update to show all chunks
|
// Not drawing - do full update to show all chunks
|
||||||
this.updateActiveMaskCanvas(true);
|
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
|
* Schedules a throttled update of the active mask canvas to prevent excessive redraws
|
||||||
@@ -903,18 +1009,12 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
drawBrushPreview(viewCoords) {
|
drawBrushPreview(viewCoords) {
|
||||||
if (!this.previewVisible || this.isDrawing) {
|
if (!this.previewVisible || this.isDrawing) {
|
||||||
this.clearPreview();
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.clearPreview();
|
// Use overlay canvas instead of preview canvas for brush cursor
|
||||||
const zoom = this.canvasInstance.viewport.zoom;
|
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||||
const radius = (this.brushSize / 2) * zoom;
|
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
clearPreview() {
|
clearPreview() {
|
||||||
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
||||||
@@ -1252,6 +1352,23 @@ export class MaskTool {
|
|||||||
this.canvasInstance.render();
|
this.canvasInstance.render();
|
||||||
log.info("Cleared all mask data from all chunks");
|
log.info("Cleared all mask data from all chunks");
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Clears all chunks and restores mask from saved state
|
||||||
|
* This is used during undo/redo operations to ensure clean state restoration
|
||||||
|
*/
|
||||||
|
restoreMaskFromSavedState(savedMaskCanvas) {
|
||||||
|
// First, clear ALL chunks to ensure no leftover data
|
||||||
|
this.clearAllMaskChunks();
|
||||||
|
// Now apply the saved mask state to chunks
|
||||||
|
if (savedMaskCanvas.width > 0 && savedMaskCanvas.height > 0) {
|
||||||
|
// Apply the saved mask to the chunk system at the correct position
|
||||||
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
|
this.applyMaskCanvasToChunks(savedMaskCanvas, this.x, this.y);
|
||||||
|
}
|
||||||
|
// Update the active mask canvas to show the restored state
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
log.debug("Restored mask from saved state with clean chunk system");
|
||||||
|
}
|
||||||
getMask() {
|
getMask() {
|
||||||
// Return the current active mask canvas which shows all chunks
|
// Return the current active mask canvas which shows all chunks
|
||||||
// Only update if there are pending changes to avoid unnecessary redraws
|
// Only update if there are pending changes to avoid unnecessary redraws
|
||||||
@@ -1345,13 +1462,44 @@ export class MaskTool {
|
|||||||
this.isOverlayVisible = !this.isOverlayVisible;
|
this.isOverlayVisible = !this.isOverlayVisible;
|
||||||
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
||||||
}
|
}
|
||||||
setMask(image) {
|
setMask(image, isFromInputMask = false) {
|
||||||
// Clear existing mask chunks in the output area first
|
|
||||||
const bounds = this.canvasInstance.outputAreaBounds;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height);
|
if (isFromInputMask) {
|
||||||
// Add the new mask using the chunk system
|
// For INPUT MASK - process black background to transparent using luminance
|
||||||
this.addMask(image);
|
// Center like input images
|
||||||
log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
const centerX = bounds.x + (bounds.width - image.width) / 2;
|
||||||
|
const centerY = bounds.y + (bounds.height - image.height) / 2;
|
||||||
|
// Prepare mask where alpha = luminance (white = applied, black = transparent)
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create mask processing context");
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
const imgData = ctx.getImageData(0, 0, image.width, image.height);
|
||||||
|
const data = imgData.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||||
|
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
|
data[i] = 255; // force white color (color channels ignored downstream)
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = lum; // alpha encodes mask strength: white -> strong, black -> 0
|
||||||
|
}
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
// Clear target area and apply to chunked system at centered position
|
||||||
|
this.clearMaskInArea(centerX, centerY, image.width, image.height);
|
||||||
|
this.applyMaskCanvasToChunks(maskCanvas, centerX, centerY);
|
||||||
|
// Refresh state and UI
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
|
this.canvasInstance.render();
|
||||||
|
log.info(`MaskTool set INPUT MASK at centered position (${centerX}, ${centerY}) using luminance as alpha`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// For SAM Detector and other sources - just clear and add without processing
|
||||||
|
this.clearMaskInArea(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||||
|
this.addMask(image);
|
||||||
|
log.info(`MaskTool set mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Clears mask data in a specific area by clearing affected chunks
|
* Clears mask data in a specific area by clearing affected chunks
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.j
|
|||||||
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
||||||
import { convertToImage } from "./utils/ImageUtils.js";
|
import { convertToImage } from "./utils/ImageUtils.js";
|
||||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||||
|
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
|
||||||
const log = createModuleLogger('SAMDetectorIntegration');
|
const log = createModuleLogger('SAMDetectorIntegration');
|
||||||
/**
|
/**
|
||||||
* SAM Detector Integration for LayerForge
|
* SAM Detector Integration for LayerForge
|
||||||
@@ -241,35 +242,61 @@ async function handleSAMDetectorResult(node, resultImage) {
|
|||||||
// Try to reload the image with a fresh request
|
// Try to reload the image with a fresh request
|
||||||
log.debug("Attempting to reload SAM result image");
|
log.debug("Attempting to reload SAM result image");
|
||||||
const originalSrc = resultImage.src;
|
const originalSrc = resultImage.src;
|
||||||
// Add cache-busting parameter to force fresh load
|
// Check if it's a data URL (base64) - don't add parameters to data URLs
|
||||||
const url = new URL(originalSrc);
|
if (originalSrc.startsWith('data:')) {
|
||||||
url.searchParams.set('_t', Date.now().toString());
|
log.debug("Image is a data URL, skipping reload with parameters");
|
||||||
await new Promise((resolve, reject) => {
|
// For data URLs, just ensure the image is loaded
|
||||||
const img = new Image();
|
if (!resultImage.complete || resultImage.naturalWidth === 0) {
|
||||||
img.crossOrigin = "anonymous";
|
await new Promise((resolve, reject) => {
|
||||||
img.onload = () => {
|
const img = new Image();
|
||||||
// Copy the loaded image data to the original image
|
img.onload = () => {
|
||||||
resultImage.src = img.src;
|
resultImage.width = img.width;
|
||||||
resultImage.width = img.width;
|
resultImage.height = img.height;
|
||||||
resultImage.height = img.height;
|
log.debug("Data URL image loaded successfully", {
|
||||||
log.debug("SAM result image reloaded successfully", {
|
width: img.width,
|
||||||
width: img.width,
|
height: img.height
|
||||||
height: img.height,
|
});
|
||||||
originalSrc: originalSrc,
|
resolve(img);
|
||||||
newSrc: img.src
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to load data URL image", error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = originalSrc; // Use original src without modifications
|
||||||
});
|
});
|
||||||
resolve(img);
|
}
|
||||||
};
|
}
|
||||||
img.onerror = (error) => {
|
else {
|
||||||
log.error("Failed to reload SAM result image", {
|
// For regular URLs, add cache-busting parameter
|
||||||
originalSrc: originalSrc,
|
const url = new URL(originalSrc);
|
||||||
newSrc: url.toString(),
|
url.searchParams.set('_t', Date.now().toString());
|
||||||
error: error
|
await new Promise((resolve, reject) => {
|
||||||
});
|
const img = new Image();
|
||||||
reject(error);
|
img.crossOrigin = "anonymous";
|
||||||
};
|
img.onload = () => {
|
||||||
img.src = url.toString();
|
// Copy the loaded image data to the original image
|
||||||
});
|
resultImage.src = img.src;
|
||||||
|
resultImage.width = img.width;
|
||||||
|
resultImage.height = img.height;
|
||||||
|
log.debug("SAM result image reloaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: img.src
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to reload SAM result image", {
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: url.toString(),
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -289,27 +316,37 @@ async function handleSAMDetectorResult(node, resultImage) {
|
|||||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||||
log.debug("Checking canvas and maskTool availability", {
|
log.debug("Checking canvas and maskTool availability", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasCanvasProperty: !!canvas.canvas,
|
||||||
|
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
|
||||||
hasMaskTool: !!canvas.maskTool,
|
hasMaskTool: !!canvas.maskTool,
|
||||||
|
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
|
||||||
maskToolType: typeof canvas.maskTool,
|
maskToolType: typeof canvas.maskTool,
|
||||||
|
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas)
|
canvasKeys: Object.keys(canvas)
|
||||||
});
|
});
|
||||||
if (!canvas.maskTool) {
|
// Get the actual Canvas object and its maskTool
|
||||||
|
const actualCanvas = canvas.canvas || canvas;
|
||||||
|
const maskTool = actualCanvas.maskTool;
|
||||||
|
if (!maskTool) {
|
||||||
log.error("MaskTool is not available. Canvas state:", {
|
log.error("MaskTool is not available. Canvas state:", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasActualCanvas: !!actualCanvas,
|
||||||
canvasConstructor: canvas.constructor.name,
|
canvasConstructor: canvas.constructor.name,
|
||||||
|
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas),
|
canvasKeys: Object.keys(canvas),
|
||||||
maskToolValue: canvas.maskTool
|
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
|
||||||
|
maskToolValue: maskTool
|
||||||
});
|
});
|
||||||
throw new Error("Mask tool not available or not initialized");
|
throw new Error("Mask tool not available or not initialized");
|
||||||
}
|
}
|
||||||
log.debug("Applying SAM mask to canvas using addMask method");
|
log.debug("Applying SAM mask to canvas using setMask method");
|
||||||
// Use the addMask method which overlays on existing mask without clearing it
|
// Use the setMask method which clears existing mask and sets new one
|
||||||
canvas.maskTool.addMask(maskAsImage);
|
maskTool.setMask(maskAsImage);
|
||||||
// Update canvas and save state (same as MaskEditorIntegration)
|
// Update canvas and save state (same as MaskEditorIntegration)
|
||||||
canvas.render();
|
actualCanvas.render();
|
||||||
canvas.saveState();
|
actualCanvas.saveState();
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
await updateNodePreview(canvas, node, true);
|
await updateNodePreview(actualCanvas, node, true);
|
||||||
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||||
// Show success notification
|
// Show success notification
|
||||||
showSuccessNotification("SAM Detector mask applied to LayerForge!");
|
showSuccessNotification("SAM Detector mask applied to LayerForge!");
|
||||||
@@ -324,6 +361,8 @@ async function handleSAMDetectorResult(node, resultImage) {
|
|||||||
node.samOriginalImgSrc = null;
|
node.samOriginalImgSrc = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Store original onClipspaceEditorSave function to restore later
|
||||||
|
let originalOnClipspaceEditorSave = null;
|
||||||
// Function to setup SAM Detector hook in menu options
|
// Function to setup SAM Detector hook in menu options
|
||||||
export function setupSAMDetectorHook(node, options) {
|
export function setupSAMDetectorHook(node, options) {
|
||||||
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||||
@@ -337,18 +376,56 @@ export function setupSAMDetectorHook(node, options) {
|
|||||||
try {
|
try {
|
||||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||||
// Automatically send canvas to clipspace and start monitoring
|
// Automatically send canvas to clipspace and start monitoring
|
||||||
if (node.canvasWidget && node.canvasWidget.canvas) {
|
if (node.canvasWidget) {
|
||||||
const canvas = node.canvasWidget; // canvasWidget IS the Canvas object
|
const canvasWidget = node.canvasWidget;
|
||||||
// Use ImageUploadUtils to upload canvas
|
const canvas = canvasWidget.canvas || canvasWidget; // Get actual Canvas object
|
||||||
|
// Use ImageUploadUtils to upload canvas and get server URL (Impact Pack compatibility)
|
||||||
const uploadResult = await uploadCanvasAsImage(canvas, {
|
const uploadResult = await uploadCanvasAsImage(canvas, {
|
||||||
filenamePrefix: 'layerforge-sam',
|
filenamePrefix: 'layerforge-sam',
|
||||||
nodeId: node.id
|
nodeId: node.id
|
||||||
});
|
});
|
||||||
|
log.debug("Uploaded canvas for SAM Detector", {
|
||||||
|
filename: uploadResult.filename,
|
||||||
|
imageUrl: uploadResult.imageUrl,
|
||||||
|
width: uploadResult.imageElement.width,
|
||||||
|
height: uploadResult.imageElement.height
|
||||||
|
});
|
||||||
// Set the image to the node for clipspace
|
// Set the image to the node for clipspace
|
||||||
node.imgs = [uploadResult.imageElement];
|
node.imgs = [uploadResult.imageElement];
|
||||||
node.clipspaceImg = 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
|
// Copy to ComfyUI clipspace
|
||||||
ComfyApp.copyToClipspace(node);
|
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
|
// Start monitoring for SAM Detector results
|
||||||
startSAMDetectorMonitoring(node);
|
startSAMDetectorMonitoring(node);
|
||||||
log.info("Canvas automatically sent to clipspace and monitoring started");
|
log.info("Canvas automatically sent to clipspace and monitoring started");
|
||||||
|
|||||||
170
js/css/blend_mode_menu.css
Normal file
170
js/css/blend_mode_menu.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/* Blend Mode Menu Styles */
|
||||||
|
#blend-mode-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 10000;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-title-bar {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-title-text {
|
||||||
|
flex: 1;
|
||||||
|
cursor: move;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button:focus {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-content {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-container {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-label {
|
||||||
|
color: white;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-container {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option {
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option:hover {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option.active {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
display: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 550;
|
font-weight: 550;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -346,6 +346,11 @@
|
|||||||
background-color: #4a4a4a !important;
|
background-color: #4a4a4a !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clipboard-switch.disabled .switch-labels {
|
||||||
|
color: #777 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.painter-separator {
|
.painter-separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -633,7 +638,7 @@
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
z-index: 111;
|
z-index: 999999;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
230
js/css/layers_panel.css
Normal file
230
js/css/layers_panel.css
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/* Layers Panel Styles */
|
||||||
|
.layers-panel {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:hover {
|
||||||
|
background: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:active {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:disabled {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #666666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:disabled:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.selected {
|
||||||
|
background: #2d5aa0 !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name.editing {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border: 1px solid #6a6a6a;
|
||||||
|
outline: none;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-insertion-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #4a7bc8;
|
||||||
|
border-radius: 1px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-track {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility-toggle {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon container styles */
|
||||||
|
.layers-panel .icon-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.visibility-hidden {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.visibility-hidden img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.fallback-text {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createModuleLogger } from "./LoggerUtils.js";
|
import { createModuleLogger } from "./LoggerUtils.js";
|
||||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
||||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||||
|
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../scripts/api.js";
|
import { api } from "../../../scripts/api.js";
|
||||||
// @ts-ignore
|
|
||||||
import { ComfyApp } from "../../../scripts/app.js";
|
|
||||||
const log = createModuleLogger('ClipboardManager');
|
const log = createModuleLogger('ClipboardManager');
|
||||||
export class ClipboardManager {
|
export class ClipboardManager {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
@@ -39,7 +38,12 @@ export class ClipboardManager {
|
|||||||
*/
|
*/
|
||||||
this.tryClipspacePaste = withErrorHandling(async (addMode) => {
|
this.tryClipspacePaste = withErrorHandling(async (addMode) => {
|
||||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
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) {
|
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||||
const clipspaceImage = this.canvas.node.imgs[0];
|
const clipspaceImage = this.canvas.node.imgs[0];
|
||||||
if (clipspaceImage && clipspaceImage.src) {
|
if (clipspaceImage && clipspaceImage.src) {
|
||||||
|
|||||||
99
js/utils/ClipspaceUtils.js
Normal file
99
js/utils/ClipspaceUtils.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { createModuleLogger } from "./LoggerUtils.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import { ComfyApp } from "../../../scripts/app.js";
|
||||||
|
const log = createModuleLogger('ClipspaceUtils');
|
||||||
|
/**
|
||||||
|
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
|
||||||
|
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
|
||||||
|
*/
|
||||||
|
export function validateAndFixClipspace() {
|
||||||
|
log.debug("Validating and fixing clipspace structure");
|
||||||
|
// Check if clipspace exists
|
||||||
|
if (!ComfyApp.clipspace) {
|
||||||
|
log.debug("ComfyUI clipspace is not available");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Validate clipspace structure
|
||||||
|
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
|
||||||
|
log.debug("ComfyUI clipspace has no images");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log.debug("Current clipspace state:", {
|
||||||
|
hasImgs: !!ComfyApp.clipspace.imgs,
|
||||||
|
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||||
|
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||||
|
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||||
|
img_paste_mode: ComfyApp.clipspace.img_paste_mode
|
||||||
|
});
|
||||||
|
// Ensure required indices are set
|
||||||
|
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
|
||||||
|
ComfyApp.clipspace.selectedIndex = 0;
|
||||||
|
log.debug("Fixed clipspace selectedIndex to 0");
|
||||||
|
}
|
||||||
|
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = 0;
|
||||||
|
log.debug("Fixed clipspace combinedIndex to 0");
|
||||||
|
}
|
||||||
|
if (!ComfyApp.clipspace.img_paste_mode) {
|
||||||
|
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||||
|
log.debug("Fixed clipspace img_paste_mode to 'selected'");
|
||||||
|
}
|
||||||
|
// Ensure indices are within bounds
|
||||||
|
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
|
||||||
|
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
|
||||||
|
ComfyApp.clipspace.selectedIndex = maxIndex;
|
||||||
|
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
|
||||||
|
}
|
||||||
|
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = maxIndex;
|
||||||
|
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
|
||||||
|
}
|
||||||
|
// Verify the image at combinedIndex exists and has src
|
||||||
|
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||||
|
if (!combinedImg || !combinedImg.src) {
|
||||||
|
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
|
||||||
|
// Try to use the first available image
|
||||||
|
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
|
||||||
|
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = i;
|
||||||
|
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final check - if still no valid image found
|
||||||
|
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||||
|
if (!finalImg || !finalImg.src) {
|
||||||
|
log.error("No valid images found in clipspace after attempting fixes");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.debug("Final clipspace structure:", {
|
||||||
|
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||||
|
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||||
|
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
|
||||||
|
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||||
|
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
|
||||||
|
* @param {any} node - The ComfyUI node to paste to
|
||||||
|
* @returns {boolean} - True if paste was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export function safeClipspacePaste(node) {
|
||||||
|
log.debug("Attempting safe clipspace paste");
|
||||||
|
if (!validateAndFixClipspace()) {
|
||||||
|
log.debug("Clipspace validation failed, cannot paste");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ComfyApp.pasteFromClipspace(node);
|
||||||
|
log.debug("Successfully called pasteFromClipspace");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Error calling pasteFromClipspace:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -314,3 +314,102 @@ export function canvasToMaskImage(canvas) {
|
|||||||
img.src = canvas.toDataURL();
|
img.src = canvas.toDataURL();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Scales an image to fit within specified bounds while maintaining aspect ratio
|
||||||
|
* @param image - Image to scale
|
||||||
|
* @param targetWidth - Target width to fit within
|
||||||
|
* @param targetHeight - Target height to fit within
|
||||||
|
* @returns Promise with scaled Image element
|
||||||
|
*/
|
||||||
|
export async function scaleImageToFit(image, targetWidth, targetHeight) {
|
||||||
|
const scale = Math.min(targetWidth / image.width, targetHeight / image.height);
|
||||||
|
const scaledWidth = Math.max(1, Math.round(image.width * scale));
|
||||||
|
const scaledHeight = Math.max(1, Math.round(image.height * scale));
|
||||||
|
const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create scaled image context");
|
||||||
|
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const scaledImg = new Image();
|
||||||
|
scaledImg.onload = () => resolve(scaledImg);
|
||||||
|
scaledImg.onerror = reject;
|
||||||
|
scaledImg.src = canvas.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Unified tensor to image data conversion
|
||||||
|
* Handles both RGB images and grayscale masks
|
||||||
|
* @param tensor - Input tensor data
|
||||||
|
* @param mode - 'rgb' for images or 'grayscale' for masks
|
||||||
|
* @returns ImageData object
|
||||||
|
*/
|
||||||
|
export function tensorToImageData(tensor, mode = 'rgb') {
|
||||||
|
try {
|
||||||
|
const shape = tensor.shape;
|
||||||
|
const height = shape[1];
|
||||||
|
const width = shape[2];
|
||||||
|
const channels = shape[3] || 1; // Default to 1 for masks
|
||||||
|
log.debug("Converting tensor:", { shape, channels, mode });
|
||||||
|
const imageData = new ImageData(width, height);
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
const flatData = tensor.data;
|
||||||
|
const pixelCount = width * height;
|
||||||
|
const min = tensor.min_val ?? 0;
|
||||||
|
const max = tensor.max_val ?? 1;
|
||||||
|
const denom = (max - min) || 1;
|
||||||
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
|
const pixelIndex = i * 4;
|
||||||
|
const tensorIndex = i * channels;
|
||||||
|
let lum;
|
||||||
|
if (mode === 'grayscale' || channels === 1) {
|
||||||
|
lum = flatData[tensorIndex];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Compute luminance for RGB
|
||||||
|
const r = flatData[tensorIndex + 0] ?? 0;
|
||||||
|
const g = flatData[tensorIndex + 1] ?? 0;
|
||||||
|
const b = flatData[tensorIndex + 2] ?? 0;
|
||||||
|
lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
}
|
||||||
|
let norm = (lum - min) / denom;
|
||||||
|
if (!isFinite(norm))
|
||||||
|
norm = 0;
|
||||||
|
norm = Math.max(0, Math.min(1, norm));
|
||||||
|
const value = Math.round(norm * 255);
|
||||||
|
if (mode === 'grayscale') {
|
||||||
|
// For masks: RGB = value, A = 255 (MaskTool reads luminance)
|
||||||
|
data[pixelIndex] = value;
|
||||||
|
data[pixelIndex + 1] = value;
|
||||||
|
data[pixelIndex + 2] = value;
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// For images: RGB from channels, A = 255
|
||||||
|
for (let c = 0; c < Math.min(3, channels); c++) {
|
||||||
|
const channelValue = flatData[tensorIndex + c];
|
||||||
|
const channelNorm = (channelValue - min) / denom;
|
||||||
|
data[pixelIndex + c] = Math.round(channelNorm * 255);
|
||||||
|
}
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageData.data.set(data);
|
||||||
|
return imageData;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Error converting tensor:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Creates an HTMLImageElement from ImageData
|
||||||
|
* @param imageData - Input ImageData
|
||||||
|
* @returns Promise with HTMLImageElement
|
||||||
|
*/
|
||||||
|
export async function createImageFromImageData(imageData) {
|
||||||
|
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create canvas context");
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "layerforge"
|
name = "layerforge"
|
||||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||||
version = "1.5.2"
|
version = "1.5.7"
|
||||||
license = { text = "MIT License" }
|
license = { text = "MIT License" }
|
||||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||||
|
|
||||||
|
|||||||
@@ -166,10 +166,14 @@ export class BatchPreviewManager {
|
|||||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||||
if (this.maskWasVisible) {
|
if (this.maskWasVisible) {
|
||||||
this.canvas.maskTool.toggleOverlayVisibility();
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
|
||||||
if (toggleBtn) {
|
if (toggleSwitch) {
|
||||||
toggleBtn.classList.remove('primary');
|
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
|
if (checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
toggleSwitch.classList.remove('primary');
|
||||||
|
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
|
||||||
if (iconContainer) {
|
if (iconContainer) {
|
||||||
iconContainer.style.opacity = '0.5';
|
iconContainer.style.opacity = '0.5';
|
||||||
}
|
}
|
||||||
@@ -218,10 +222,14 @@ export class BatchPreviewManager {
|
|||||||
|
|
||||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||||
this.canvas.maskTool.toggleOverlayVisibility();
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
|
||||||
if (toggleBtn) {
|
if (toggleSwitch) {
|
||||||
toggleBtn.classList.add('primary');
|
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
|
if (checkbox) {
|
||||||
|
checkbox.checked = true;
|
||||||
|
}
|
||||||
|
toggleSwitch.classList.add('primary');
|
||||||
|
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
|
||||||
if (iconContainer) {
|
if (iconContainer) {
|
||||||
iconContainer.style.opacity = '1';
|
iconContainer.style.opacity = '1';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,11 +84,18 @@ export class Canvas {
|
|||||||
node: ComfyNode;
|
node: ComfyNode;
|
||||||
offscreenCanvas: HTMLCanvasElement;
|
offscreenCanvas: HTMLCanvasElement;
|
||||||
offscreenCtx: CanvasRenderingContext2D | null;
|
offscreenCtx: CanvasRenderingContext2D | null;
|
||||||
|
overlayCanvas: HTMLCanvasElement;
|
||||||
|
overlayCtx: CanvasRenderingContext2D;
|
||||||
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
||||||
onViewportChange: (() => void) | null;
|
onViewportChange: (() => void) | null;
|
||||||
onStateChange: (() => void) | undefined;
|
onStateChange: (() => void) | undefined;
|
||||||
pendingBatchContext: any;
|
pendingBatchContext: any;
|
||||||
pendingDataCheck: number | null;
|
pendingDataCheck: number | null;
|
||||||
|
pendingInputDataCheck: number | null;
|
||||||
|
inputDataLoaded: boolean;
|
||||||
|
lastLoadedImageSrc?: string;
|
||||||
|
lastLoadedLinkId?: number;
|
||||||
|
lastLoadedMaskLinkId?: number;
|
||||||
previewVisible: boolean;
|
previewVisible: boolean;
|
||||||
requestSaveState: () => void;
|
requestSaveState: () => void;
|
||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
@@ -122,10 +129,22 @@ export class Canvas {
|
|||||||
});
|
});
|
||||||
this.offscreenCanvas = offscreenCanvas;
|
this.offscreenCanvas = offscreenCanvas;
|
||||||
this.offscreenCtx = offscreenCtx;
|
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.canvasContainer = null;
|
||||||
|
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
this.pendingDataCheck = null;
|
this.pendingDataCheck = null;
|
||||||
|
this.pendingInputDataCheck = null;
|
||||||
|
this.inputDataLoaded = false;
|
||||||
this.imageCache = new Map();
|
this.imageCache = new Map();
|
||||||
|
|
||||||
this.requestSaveState = () => {};
|
this.requestSaveState = () => {};
|
||||||
@@ -471,6 +490,11 @@ export class Canvas {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecutionStart = () => {
|
const handleExecutionStart = () => {
|
||||||
|
// Check for input data when execution starts, but don't reset the flag
|
||||||
|
log.debug('Execution started, checking for input data...');
|
||||||
|
// On start, only allow images; mask should load on mask-connect or after execution completes
|
||||||
|
this.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: 'execution_start' });
|
||||||
|
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
lastExecutionStartTime = Date.now();
|
lastExecutionStartTime = Date.now();
|
||||||
// Store a snapshot of the context for the upcoming batch
|
// Store a snapshot of the context for the upcoming batch
|
||||||
@@ -494,6 +518,10 @@ export class Canvas {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecutionSuccess = async () => {
|
const handleExecutionSuccess = async () => {
|
||||||
|
// Always check for input data after execution completes
|
||||||
|
log.debug('Execution success, checking for input data...');
|
||||||
|
await this.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: 'execution_success' });
|
||||||
|
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
log.info('Auto-refresh triggered, importing latest images.');
|
log.info('Auto-refresh triggered, importing latest images.');
|
||||||
|
|
||||||
|
|||||||
610
src/CanvasIO.ts
610
src/CanvasIO.ts
@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||||
|
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
|
||||||
import type { Canvas } from './Canvas';
|
import type { Canvas } from './Canvas';
|
||||||
import type { Layer, Shape } from './types';
|
import type { Layer, Shape } from './types';
|
||||||
|
|
||||||
@@ -269,7 +270,12 @@ export class CanvasIO {
|
|||||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||||
|
|
||||||
|
|
||||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
|
throw new Error(
|
||||||
|
`Failed to get confirmation from server for node ${nodeId}. ` +
|
||||||
|
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
|
||||||
|
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
|
||||||
|
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,22 +283,12 @@ export class CanvasIO {
|
|||||||
try {
|
try {
|
||||||
log.debug("Adding input to canvas:", { inputImage });
|
log.debug("Adding input to canvas:", { inputImage });
|
||||||
|
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
// Use unified tensorToImageData for RGB image
|
||||||
if (!tempCtx) throw new Error("Could not create temp context");
|
const imageData = tensorToImageData(inputImage, 'rgb');
|
||||||
|
if (!imageData) throw new Error("Failed to convert input image tensor");
|
||||||
|
|
||||||
const imgData = new ImageData(
|
// Create HTMLImageElement from ImageData
|
||||||
new Uint8ClampedArray(inputImage.data),
|
const image = await createImageFromImageData(imageData);
|
||||||
inputImage.width,
|
|
||||||
inputImage.height
|
|
||||||
);
|
|
||||||
tempCtx.putImageData(imgData, 0, 0);
|
|
||||||
|
|
||||||
const image = new Image();
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
image.onload = resolve;
|
|
||||||
image.onerror = reject;
|
|
||||||
image.src = tempCanvas.toDataURL();
|
|
||||||
});
|
|
||||||
|
|
||||||
const bounds = this.canvas.outputAreaBounds;
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
const scale = Math.min(
|
const scale = Math.min(
|
||||||
@@ -328,23 +324,10 @@ export class CanvasIO {
|
|||||||
throw new Error("Invalid tensor data");
|
throw new Error("Invalid tensor data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
const imageData = tensorToImageData(tensor, 'rgb');
|
||||||
if (!ctx) throw new Error("Could not create canvas context");
|
if (!imageData) throw new Error("Failed to convert tensor to image data");
|
||||||
|
|
||||||
const imageData = new ImageData(
|
return await createImageFromImageData(imageData);
|
||||||
new Uint8ClampedArray(tensor.data),
|
|
||||||
tensor.width,
|
|
||||||
tensor.height
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
|
|
||||||
img.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Error converting tensor to image:", error);
|
log.error("Error converting tensor to image:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -367,6 +350,16 @@ export class CanvasIO {
|
|||||||
try {
|
try {
|
||||||
log.info("Starting node data initialization...");
|
log.info("Starting node data initialization...");
|
||||||
|
|
||||||
|
// First check for input data from the backend (new feature)
|
||||||
|
await this.checkForInputData();
|
||||||
|
|
||||||
|
// If we've already loaded input data, don't continue with old initialization
|
||||||
|
if (this.canvas.inputDataLoaded) {
|
||||||
|
log.debug("Input data already loaded, skipping old initialization");
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.canvas.node || !(this.canvas.node as any).inputs) {
|
if (!this.canvas.node || !(this.canvas.node as any).inputs) {
|
||||||
log.debug("Node or inputs not ready");
|
log.debug("Node or inputs not ready");
|
||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
@@ -374,6 +367,14 @@ export class CanvasIO {
|
|||||||
|
|
||||||
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
|
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
|
||||||
const imageLinkId = (this.canvas.node as any).inputs[0].link;
|
const imageLinkId = (this.canvas.node as any).inputs[0].link;
|
||||||
|
|
||||||
|
// Check if we already loaded this link
|
||||||
|
if (this.canvas.lastLoadedLinkId === imageLinkId) {
|
||||||
|
log.debug(`Link ${imageLinkId} already loaded via new system, marking as initialized`);
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const imageData = (window as any).app.nodeOutputs[imageLinkId];
|
const imageData = (window as any).app.nodeOutputs[imageLinkId];
|
||||||
|
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
@@ -384,6 +385,9 @@ export class CanvasIO {
|
|||||||
log.debug("Image data not available yet");
|
log.debug("Image data not available yet");
|
||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No input connected, mark as initialized to stop repeated checks
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
|
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
|
||||||
@@ -402,6 +406,439 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkForInputData(options?: { allowImage?: boolean; allowMask?: boolean; reason?: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
const nodeId = this.canvas.node.id;
|
||||||
|
const allowImage = options?.allowImage ?? true;
|
||||||
|
const allowMask = options?.allowMask ?? true;
|
||||||
|
const reason = options?.reason ?? 'unspecified';
|
||||||
|
log.info(`Checking for input data for node ${nodeId}... opts: image=${allowImage}, mask=${allowMask}, reason=${reason}`);
|
||||||
|
|
||||||
|
// Track loaded links separately for image and mask
|
||||||
|
let imageLoaded = false;
|
||||||
|
let maskLoaded = false;
|
||||||
|
let imageChanged = false;
|
||||||
|
|
||||||
|
// First, try to get data from connected node's output if available (IMAGES)
|
||||||
|
if (allowImage && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
|
const linkId = this.canvas.node.inputs[0].link;
|
||||||
|
const graph = (this.canvas.node as any).graph;
|
||||||
|
|
||||||
|
// Always check if images have changed first
|
||||||
|
if (graph) {
|
||||||
|
const link = graph.links[linkId];
|
||||||
|
if (link) {
|
||||||
|
const sourceNode = graph.getNodeById(link.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// Create current batch identifier (all image sources combined)
|
||||||
|
const currentBatchImageSrcs = sourceNode.imgs.map((img: HTMLImageElement) => img.src).join('|');
|
||||||
|
|
||||||
|
// Check if this is the same link we loaded before
|
||||||
|
if (this.canvas.lastLoadedLinkId === linkId) {
|
||||||
|
// Same link, check if images actually changed
|
||||||
|
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
|
||||||
|
log.info(`Batch images changed for link ${linkId} (${sourceNode.imgs.length} images), will reload...`);
|
||||||
|
log.debug(`Previous batch hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
|
||||||
|
log.debug(`Current batch hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
|
||||||
|
imageChanged = true;
|
||||||
|
// Clear the inputDataLoaded flag to force reload from backend
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
// Clear the lastLoadedImageSrc to force reload
|
||||||
|
this.canvas.lastLoadedImageSrc = undefined;
|
||||||
|
// Clear backend data to force fresh load
|
||||||
|
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
|
||||||
|
.then(() => log.debug("Backend input data cleared due to image change"))
|
||||||
|
.catch(err => log.error("Failed to clear backend data:", err));
|
||||||
|
} else {
|
||||||
|
log.debug(`Batch images for link ${linkId} unchanged (${sourceNode.imgs.length} images)`);
|
||||||
|
imageLoaded = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Different link or first load
|
||||||
|
log.info(`New link ${linkId} detected, will load ${sourceNode.imgs.length} images`);
|
||||||
|
imageChanged = false; // It's not a change, it's a new link
|
||||||
|
imageLoaded = false; // Need to load
|
||||||
|
// Reset the inputDataLoaded flag for new link
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageLoaded || imageChanged) {
|
||||||
|
// Reset the inputDataLoaded flag when images change
|
||||||
|
if (imageChanged) {
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
log.info("Resetting inputDataLoaded flag due to image change");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((this.canvas.node as any).graph) {
|
||||||
|
const graph2 = (this.canvas.node as any).graph;
|
||||||
|
const link2 = graph2.links[linkId];
|
||||||
|
if (link2) {
|
||||||
|
const sourceNode = graph2.getNodeById(link2.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// The connected node has images in its output - handle multiple images (batch)
|
||||||
|
log.info(`Found ${sourceNode.imgs.length} image(s) in connected node's output, loading all`);
|
||||||
|
|
||||||
|
// Create a combined source identifier for batch detection
|
||||||
|
const batchImageSrcs = sourceNode.imgs.map((img: HTMLImageElement) => img.src).join('|');
|
||||||
|
|
||||||
|
// Mark this link and batch sources as loaded
|
||||||
|
this.canvas.lastLoadedLinkId = linkId;
|
||||||
|
this.canvas.lastLoadedImageSrc = batchImageSrcs;
|
||||||
|
|
||||||
|
// Don't clear layers - just add new ones
|
||||||
|
if (imageChanged) {
|
||||||
|
log.info("Image change detected, will add new layers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine add mode
|
||||||
|
const fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
|
||||||
|
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
|
||||||
|
|
||||||
|
// Add all images from the batch as separate layers
|
||||||
|
for (let i = 0; i < sourceNode.imgs.length; i++) {
|
||||||
|
const img = sourceNode.imgs[i];
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(
|
||||||
|
img,
|
||||||
|
{ name: `Batch Image ${i + 1}` }, // Give each layer a unique name
|
||||||
|
addMode,
|
||||||
|
this.canvas.outputAreaBounds
|
||||||
|
);
|
||||||
|
log.debug(`Added batch image ${i + 1}/${sourceNode.imgs.length} to canvas`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
imageLoaded = true;
|
||||||
|
log.info(`All ${sourceNode.imgs.length} input images from batch added as separate layers`);
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mask input separately (from nodeOutputs) ONLY when allowed
|
||||||
|
if (allowMask && this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
||||||
|
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||||
|
|
||||||
|
// Check if we already loaded this mask link
|
||||||
|
if (this.canvas.lastLoadedMaskLinkId === maskLinkId) {
|
||||||
|
log.debug(`Mask link ${maskLinkId} already loaded`);
|
||||||
|
maskLoaded = true;
|
||||||
|
} else {
|
||||||
|
// Try to get mask tensor from nodeOutputs using origin_id (not link id)
|
||||||
|
const graph = (this.canvas.node as any).graph;
|
||||||
|
let maskOutput = null;
|
||||||
|
|
||||||
|
if (graph) {
|
||||||
|
const link = graph.links[maskLinkId];
|
||||||
|
if (link && link.origin_id) {
|
||||||
|
// Use origin_id to get the actual node output
|
||||||
|
const nodeOutput = (window as any).app?.nodeOutputs?.[link.origin_id];
|
||||||
|
log.debug(`Looking for mask output from origin node ${link.origin_id}, found:`, !!nodeOutput);
|
||||||
|
|
||||||
|
if (nodeOutput) {
|
||||||
|
log.debug(`Node ${link.origin_id} output structure:`, {
|
||||||
|
hasData: !!nodeOutput.data,
|
||||||
|
hasShape: !!nodeOutput.shape,
|
||||||
|
dataType: typeof nodeOutput.data,
|
||||||
|
shapeType: typeof nodeOutput.shape,
|
||||||
|
keys: Object.keys(nodeOutput)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only use if it has actual tensor data
|
||||||
|
if (nodeOutput.data && nodeOutput.shape) {
|
||||||
|
maskOutput = nodeOutput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maskOutput && maskOutput.data && maskOutput.shape) {
|
||||||
|
try {
|
||||||
|
// Derive dimensions from shape or explicit width/height
|
||||||
|
let width = (maskOutput.width as number) || 0;
|
||||||
|
let height = (maskOutput.height as number) || 0;
|
||||||
|
const shape = maskOutput.shape as number[]; // e.g. [1,H,W] or [1,H,W,1]
|
||||||
|
if ((!width || !height) && Array.isArray(shape)) {
|
||||||
|
if (shape.length >= 3) {
|
||||||
|
height = shape[1];
|
||||||
|
width = shape[2];
|
||||||
|
} else if (shape.length === 2) {
|
||||||
|
height = shape[0];
|
||||||
|
width = shape[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!width || !height) {
|
||||||
|
throw new Error("Cannot determine mask dimensions from nodeOutputs");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine channels count
|
||||||
|
let channels = 1;
|
||||||
|
if (Array.isArray(shape) && shape.length >= 4) {
|
||||||
|
channels = shape[3];
|
||||||
|
} else if ((maskOutput as any).channels) {
|
||||||
|
channels = (maskOutput as any).channels;
|
||||||
|
} else {
|
||||||
|
const len = (maskOutput.data as any).length;
|
||||||
|
channels = Math.max(1, Math.floor(len / (width * height)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use unified tensorToImageData for masks
|
||||||
|
const maskImageData = tensorToImageData(maskOutput, 'grayscale');
|
||||||
|
if (!maskImageData) throw new Error("Failed to convert mask tensor to image data");
|
||||||
|
|
||||||
|
// Create canvas and put image data
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create mask context");
|
||||||
|
ctx.putImageData(maskImageData, 0, 0);
|
||||||
|
|
||||||
|
// Convert to HTMLImageElement
|
||||||
|
const maskImg = await createImageFromSource(maskCanvas.toDataURL());
|
||||||
|
|
||||||
|
// Respect fit_on_add (scale to output area)
|
||||||
|
const widgets = this.canvas.node.widgets;
|
||||||
|
const fitOnAddWidget = widgets ? widgets.find((w: any) => w.name === "fit_on_add") : null;
|
||||||
|
const shouldFit = fitOnAddWidget && fitOnAddWidget.value;
|
||||||
|
|
||||||
|
let finalMaskImg: HTMLImageElement = maskImg;
|
||||||
|
if (shouldFit) {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to MaskTool (centers internally)
|
||||||
|
if (this.canvas.maskTool) {
|
||||||
|
this.canvas.maskTool.setMask(finalMaskImg, true);
|
||||||
|
(this.canvas as any).maskAppliedFromInput = true;
|
||||||
|
this.canvas.canvasState.saveMaskState();
|
||||||
|
this.canvas.render();
|
||||||
|
// Mark this mask link as loaded to avoid re-applying
|
||||||
|
this.canvas.lastLoadedMaskLinkId = maskLinkId;
|
||||||
|
maskLoaded = true;
|
||||||
|
log.info("Applied input mask from nodeOutputs immediately on connection" + (shouldFit ? " (fitted to output area)" : ""));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn("Failed to apply mask from nodeOutputs immediately; will wait for backend input_mask after execution", err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// nodeOutputs exist but don't have tensor data yet (need workflow execution)
|
||||||
|
log.info(`Mask node ${(this.canvas.node as any).graph?.links[maskLinkId]?.origin_id} found but has no tensor data yet. Mask will be applied automatically after workflow execution.`);
|
||||||
|
// Don't retry - data won't be available until workflow runs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check backend if we have actual inputs connected
|
||||||
|
const hasImageInput = this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link;
|
||||||
|
const hasMaskInput = this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link;
|
||||||
|
|
||||||
|
// If mask input is disconnected, clear any currently applied mask to ensure full separation
|
||||||
|
if (!hasMaskInput) {
|
||||||
|
(this.canvas as any).maskAppliedFromInput = false;
|
||||||
|
this.canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Mask input disconnected - cleared mask to enforce separation from input_image");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasImageInput && !hasMaskInput) {
|
||||||
|
log.debug("No inputs connected, skipping backend check");
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip backend check during mask connection if we didn't get immediate data
|
||||||
|
if (reason === "mask_connect" && !maskLoaded) {
|
||||||
|
log.info("No immediate mask data available during connection, skipping backend check to avoid stale data. Will check after execution.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check backend for input data only if we have connected inputs
|
||||||
|
const response = await fetch(`/layerforge/get_input_data/${nodeId}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.has_input) {
|
||||||
|
// Dedupe: skip only if backend payload matches last loaded batch hash
|
||||||
|
let backendBatchHash: string | undefined;
|
||||||
|
if (result.data?.input_images_batch && Array.isArray(result.data.input_images_batch)) {
|
||||||
|
backendBatchHash = result.data.input_images_batch.map((i: any) => i.data).join('|');
|
||||||
|
} else if (result.data?.input_image) {
|
||||||
|
backendBatchHash = result.data.input_image;
|
||||||
|
}
|
||||||
|
// Check mask separately - don't skip if only images are unchanged AND mask is actually connected AND allowed
|
||||||
|
const shouldCheckMask = hasMaskInput && allowMask;
|
||||||
|
|
||||||
|
if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && !shouldCheckMask) {
|
||||||
|
log.debug("Backend input data unchanged and no mask to check, skipping reload");
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
return;
|
||||||
|
} else if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && shouldCheckMask) {
|
||||||
|
log.debug("Images unchanged but need to check mask, continuing...");
|
||||||
|
imageLoaded = true; // Mark images as already loaded to skip reloading them
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already loaded image data (by checking the current link)
|
||||||
|
if (allowImage && !imageLoaded && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
|
const currentLinkId = this.canvas.node.inputs[0].link;
|
||||||
|
if (this.canvas.lastLoadedLinkId !== currentLinkId) {
|
||||||
|
// Mark this link as loaded
|
||||||
|
this.canvas.lastLoadedLinkId = currentLinkId;
|
||||||
|
imageLoaded = false; // Will load from backend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mask data from backend ONLY when mask input is actually connected AND allowed
|
||||||
|
// Only reset if the mask link actually changed
|
||||||
|
if (allowMask && hasMaskInput && this.canvas.node.inputs && this.canvas.node.inputs[1]) {
|
||||||
|
const currentMaskLinkId = this.canvas.node.inputs[1].link;
|
||||||
|
// Only reset if this is a different mask link than what we loaded before
|
||||||
|
if (this.canvas.lastLoadedMaskLinkId !== currentMaskLinkId) {
|
||||||
|
maskLoaded = false;
|
||||||
|
log.debug(`New mask input detected (${currentMaskLinkId}), will check backend for mask data`);
|
||||||
|
} else {
|
||||||
|
log.debug(`Same mask input (${currentMaskLinkId}), mask already loaded`);
|
||||||
|
maskLoaded = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No mask input connected, or mask loading not allowed right now
|
||||||
|
maskLoaded = true; // Mark as loaded to skip mask processing
|
||||||
|
if (!allowMask) {
|
||||||
|
log.debug("Mask loading is currently disabled by caller, skipping mask check");
|
||||||
|
} else {
|
||||||
|
log.debug("No mask input connected, skipping mask check");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Input data found from backend, adding to canvas");
|
||||||
|
const inputData = result.data;
|
||||||
|
|
||||||
|
// Compute backend batch hash for dedupe and state
|
||||||
|
let backendHashNow: string | undefined;
|
||||||
|
if (inputData?.input_images_batch && Array.isArray(inputData.input_images_batch)) {
|
||||||
|
backendHashNow = inputData.input_images_batch.map((i: any) => i.data).join('|');
|
||||||
|
} else if (inputData?.input_image) {
|
||||||
|
backendHashNow = inputData.input_image;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just update the hash without removing any layers
|
||||||
|
if (backendHashNow) {
|
||||||
|
log.info("New backend input data detected, adding new layers");
|
||||||
|
this.canvas.lastLoadedImageSrc = backendHashNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark that we've loaded input data for this execution
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
|
||||||
|
// Determine add mode based on fit_on_add setting
|
||||||
|
const widgets = this.canvas.node.widgets;
|
||||||
|
const fitOnAddWidget = widgets ? widgets.find((w: any) => w.name === "fit_on_add") : null;
|
||||||
|
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
|
||||||
|
|
||||||
|
// Load input image(s) only if image input is actually connected, not already loaded, and allowed
|
||||||
|
if (allowImage && !imageLoaded && hasImageInput) {
|
||||||
|
if (inputData.input_images_batch) {
|
||||||
|
// Handle batch of images
|
||||||
|
const batch = inputData.input_images_batch;
|
||||||
|
log.info(`Processing batch of ${batch.length} images from backend`);
|
||||||
|
|
||||||
|
for (let i = 0; i < batch.length; i++) {
|
||||||
|
const imgData = batch[i];
|
||||||
|
const img = await createImageFromSource(imgData.data);
|
||||||
|
|
||||||
|
// Add image to canvas with unique name
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(
|
||||||
|
img,
|
||||||
|
{ name: `Batch Image ${i + 1}` },
|
||||||
|
addMode,
|
||||||
|
this.canvas.outputAreaBounds
|
||||||
|
);
|
||||||
|
|
||||||
|
log.debug(`Added batch image ${i + 1}/${batch.length} from backend`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`All ${batch.length} batch images added from backend`);
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
|
||||||
|
} else if (inputData.input_image) {
|
||||||
|
// Handle single image (backward compatibility)
|
||||||
|
const img = await createImageFromSource(inputData.input_image);
|
||||||
|
|
||||||
|
// Add image to canvas at output area position
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(
|
||||||
|
img,
|
||||||
|
{},
|
||||||
|
addMode,
|
||||||
|
this.canvas.outputAreaBounds
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("Single input image added as new layer to canvas");
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
} else {
|
||||||
|
log.debug("No input image data from backend");
|
||||||
|
}
|
||||||
|
} else if (!hasImageInput && (inputData.input_images_batch || inputData.input_image)) {
|
||||||
|
log.debug("Backend has image data but no image input connected, skipping image load");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mask separately only if mask input is actually connected, allowed, and not already loaded
|
||||||
|
if (allowMask && !maskLoaded && hasMaskInput && inputData.input_mask) {
|
||||||
|
log.info("Processing input mask");
|
||||||
|
|
||||||
|
// Load mask image
|
||||||
|
const maskImg = await createImageFromSource(inputData.input_mask);
|
||||||
|
|
||||||
|
// Determine if we should fit the mask or use it at original size
|
||||||
|
const fitOnAddWidget2 = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
|
||||||
|
const shouldFit = fitOnAddWidget2 && fitOnAddWidget2.value;
|
||||||
|
|
||||||
|
let finalMaskImg: HTMLImageElement = maskImg;
|
||||||
|
if (shouldFit && this.canvas.maskTool) {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to MaskTool (centers internally)
|
||||||
|
if (this.canvas.maskTool) {
|
||||||
|
this.canvas.maskTool.setMask(finalMaskImg, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
(this.canvas as any).maskAppliedFromInput = true;
|
||||||
|
// Save the mask state
|
||||||
|
this.canvas.canvasState.saveMaskState()
|
||||||
|
|
||||||
|
log.info("Applied input mask to mask tool" + (shouldFit ? " (fitted to output area)" : " (original size)"));
|
||||||
|
} else if (!hasMaskInput && inputData.input_mask) {
|
||||||
|
log.debug("Backend has mask data but no mask input connected, skipping mask load");
|
||||||
|
} else if (!allowMask && inputData.input_mask) {
|
||||||
|
log.debug("Mask input data present in backend but mask loading is disabled by caller; skipping");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.debug("No input data from backend");
|
||||||
|
// Don't schedule another check - we'll only check when explicitly triggered
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error checking for input data:", error);
|
||||||
|
// Don't schedule another check on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleInputDataCheck(): void {
|
||||||
|
// Schedule a retry for mask data check when nodeOutputs are not ready yet
|
||||||
|
if (this.canvas.pendingInputDataCheck) {
|
||||||
|
clearTimeout(this.canvas.pendingInputDataCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.pendingInputDataCheck = window.setTimeout(() => {
|
||||||
|
this.canvas.pendingInputDataCheck = null;
|
||||||
|
log.debug("Retrying input data check for mask...");
|
||||||
|
|
||||||
|
}, 500); // Shorter delay for mask data retry
|
||||||
|
}
|
||||||
|
|
||||||
scheduleDataCheck(): void {
|
scheduleDataCheck(): void {
|
||||||
if (this.canvas.pendingDataCheck) {
|
if (this.canvas.pendingDataCheck) {
|
||||||
clearTimeout(this.canvas.pendingDataCheck);
|
clearTimeout(this.canvas.pendingDataCheck);
|
||||||
@@ -494,59 +931,11 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
convertTensorToImageData(tensor: any): ImageData | null {
|
convertTensorToImageData(tensor: any): ImageData | null {
|
||||||
try {
|
return tensorToImageData(tensor, 'rgb');
|
||||||
const shape = tensor.shape;
|
|
||||||
const height = shape[1];
|
|
||||||
const width = shape[2];
|
|
||||||
const channels = shape[3];
|
|
||||||
|
|
||||||
log.debug("Converting tensor:", {
|
|
||||||
shape: shape,
|
|
||||||
dataRange: {
|
|
||||||
min: tensor.min_val,
|
|
||||||
max: tensor.max_val
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const imageData = new ImageData(width, height);
|
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
|
||||||
|
|
||||||
const flatData = tensor.data;
|
|
||||||
const pixelCount = width * height;
|
|
||||||
|
|
||||||
for (let i = 0; i < pixelCount; i++) {
|
|
||||||
const pixelIndex = i * 4;
|
|
||||||
const tensorIndex = i * channels;
|
|
||||||
|
|
||||||
for (let c = 0; c < channels; c++) {
|
|
||||||
const value = flatData[tensorIndex + c];
|
|
||||||
|
|
||||||
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
|
|
||||||
data[pixelIndex + c] = Math.round(normalizedValue * 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
data[pixelIndex + 3] = 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
imageData.data.set(data);
|
|
||||||
return imageData;
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Error converting tensor:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
|
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
return createImageFromImageData(imageData);
|
||||||
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
|
||||||
if (!ctx) throw new Error("Could not create canvas context");
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async processMaskData(maskData: any): Promise<void> {
|
async processMaskData(maskData: any): Promise<void> {
|
||||||
@@ -613,12 +1002,7 @@ export class CanvasIO {
|
|||||||
const newLayers: (Layer | null)[] = [];
|
const newLayers: (Layer | null)[] = [];
|
||||||
|
|
||||||
for (const imageData of result.images) {
|
for (const imageData of result.images) {
|
||||||
const img = new Image();
|
const img = await createImageFromSource(imageData);
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
img.onload = resolve;
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = imageData;
|
|
||||||
});
|
|
||||||
|
|
||||||
let processedImage = img;
|
let processedImage = img;
|
||||||
|
|
||||||
@@ -647,37 +1031,31 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise<HTMLImageElement> {
|
async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
if (!ctx) {
|
||||||
if (!ctx) {
|
throw new Error("Could not create canvas context for clipping");
|
||||||
reject(new Error("Could not create canvas context for clipping"));
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw the image first
|
// Draw the image first
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
// Calculate custom shape position accounting for extensions
|
// Calculate custom shape position accounting for extensions
|
||||||
// Custom shape should maintain its relative position within the original canvas area
|
// Custom shape should maintain its relative position within the original canvas area
|
||||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||||
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
||||||
|
|
||||||
// Create a clipping mask using the shape with extension offset
|
// Create a clipping mask using the shape with extension offset
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||||
for (let i = 1; i < shape.points.length; i++) {
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
||||||
}
|
}
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Create a new image from the clipped canvas
|
// Create a new image from the clipped canvas
|
||||||
const clippedImage = new Image();
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
clippedImage.onload = () => resolve(clippedImage);
|
|
||||||
clippedImage.onerror = () => reject(new Error("Failed to create clipped image"));
|
|
||||||
clippedImage.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,36 @@ interface MouseCoordinates {
|
|||||||
view: Point;
|
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 {
|
interface InteractionState {
|
||||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape';
|
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape';
|
||||||
panStart: Point;
|
panStart: Point;
|
||||||
dragStart: Point;
|
dragStart: Point;
|
||||||
transformOrigin: Partial<Layer> & { centerX?: number, centerY?: number };
|
transformOrigin: TransformOrigin | null;
|
||||||
resizeHandle: string | null;
|
resizeHandle: string | null;
|
||||||
resizeAnchor: Point;
|
resizeAnchor: Point;
|
||||||
canvasResizeStart: Point;
|
canvasResizeStart: Point;
|
||||||
isCtrlPressed: boolean;
|
isCtrlPressed: boolean;
|
||||||
|
isMetaPressed: boolean;
|
||||||
isAltPressed: boolean;
|
isAltPressed: boolean;
|
||||||
isShiftPressed: boolean;
|
isShiftPressed: boolean;
|
||||||
isSPressed: boolean;
|
isSPressed: boolean;
|
||||||
@@ -35,17 +56,35 @@ export class CanvasInteractions {
|
|||||||
public interaction: InteractionState;
|
public interaction: InteractionState;
|
||||||
private originalLayerPositions: Map<Layer, Point>;
|
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) {
|
constructor(canvas: Canvas) {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.interaction = {
|
this.interaction = {
|
||||||
mode: 'none',
|
mode: 'none',
|
||||||
panStart: { x: 0, y: 0 },
|
panStart: { x: 0, y: 0 },
|
||||||
dragStart: { x: 0, y: 0 },
|
dragStart: { x: 0, y: 0 },
|
||||||
transformOrigin: {},
|
transformOrigin: null,
|
||||||
resizeHandle: null,
|
resizeHandle: null,
|
||||||
resizeAnchor: { x: 0, y: 0 },
|
resizeAnchor: { x: 0, y: 0 },
|
||||||
canvasResizeStart: { x: 0, y: 0 },
|
canvasResizeStart: { x: 0, y: 0 },
|
||||||
isCtrlPressed: false,
|
isCtrlPressed: false,
|
||||||
|
isMetaPressed: false,
|
||||||
isAltPressed: false,
|
isAltPressed: false,
|
||||||
isShiftPressed: false,
|
isShiftPressed: false,
|
||||||
isSPressed: 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 {
|
private preventEventDefaults(e: Event): void {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
private performZoomOperation(worldCoords: Point, zoomFactor: number): void {
|
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 mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * 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.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / 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?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,34 +158,49 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners(): void {
|
setupEventListeners(): void {
|
||||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown as EventListener);
|
||||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove as EventListener);
|
||||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp as EventListener);
|
||||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('wheel', this.onWheel as EventListener, { passive: false });
|
||||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, { passive: false });
|
this.canvas.canvas.addEventListener('keydown', this.onKeyDown as EventListener);
|
||||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('keyup', this.onKeyUp as EventListener);
|
||||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
|
|
||||||
|
|
||||||
// Add a blur event listener to the window to reset key states
|
// Add a blur event listener to the window to reset key states
|
||||||
window.addEventListener('blur', this.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.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener);
|
||||||
this.canvas.isMouseOver = true;
|
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
|
||||||
this.handleMouseEnter(e);
|
|
||||||
});
|
|
||||||
this.canvas.canvas.addEventListener('mouseleave', (e: MouseEvent) => {
|
|
||||||
this.canvas.isMouseOver = false;
|
|
||||||
this.handleMouseLeave(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('dragover', this.onDragOver as EventListener);
|
||||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter as EventListener);
|
||||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave as EventListener);
|
||||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this) as unknown 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 {
|
handleMouseDown(e: MouseEvent): void {
|
||||||
this.canvas.canvas.focus();
|
this.canvas.canvas.focus();
|
||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
|
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
||||||
this.canvas.render();
|
// Don't render here - mask tool will handle its own drawing
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,11 +260,11 @@ export class CanvasInteractions {
|
|||||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||||
|
|
||||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||||
if (e.shiftKey && e.ctrlKey) {
|
if (mods.shift && mods.ctrl) {
|
||||||
this.startCanvasMove(coords.world);
|
this.startCanvasMove(coords.world);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
// Clear custom shape when starting canvas resize
|
// Clear custom shape when starting canvas resize
|
||||||
if (this.canvas.outputAreaShape) {
|
if (this.canvas.outputAreaShape) {
|
||||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||||
@@ -222,7 +290,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0) { // Środkowy przycisk
|
if (e.button === 1) { // Środkowy przycisk
|
||||||
this.startPanning(e);
|
this.startPanning(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -241,7 +309,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||||
this.startPanningOrClearSelection(e);
|
this.startPanning(e, true); // clearSelection = true
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseMove(e: MouseEvent): void {
|
handleMouseMove(e: MouseEvent): void {
|
||||||
@@ -264,7 +332,7 @@ export class CanvasInteractions {
|
|||||||
switch (this.interaction.mode) {
|
switch (this.interaction.mode) {
|
||||||
case 'drawingMask':
|
case 'drawingMask':
|
||||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
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;
|
break;
|
||||||
case 'panning':
|
case 'panning':
|
||||||
this.panViewport(e);
|
this.panViewport(e);
|
||||||
@@ -286,6 +354,10 @@ export class CanvasInteractions {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
this.updateCursor(coords.world);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +372,7 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||||
|
// Render only once after drawing is complete
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -316,6 +389,16 @@ export class CanvasInteractions {
|
|||||||
this.logDragCompletion(coords);
|
this.logDragCompletion(coords);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle end of crop bounds transformation before resetting interaction state
|
||||||
|
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
|
||||||
|
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle end of scale transformation (normal transform mode) before resetting interaction state
|
||||||
|
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
|
||||||
|
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
|
||||||
|
}
|
||||||
|
|
||||||
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
||||||
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
||||||
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
||||||
@@ -387,8 +470,17 @@ export class CanvasInteractions {
|
|||||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||||
this.performZoomOperation(coords.world, zoomFactor);
|
this.performZoomOperation(coords.world, zoomFactor);
|
||||||
} else {
|
} else {
|
||||||
// Layer transformation when layers are selected
|
// Check if mouse is over any selected layer
|
||||||
this.handleLayerWheelTransformation(e);
|
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();
|
this.canvas.render();
|
||||||
@@ -398,14 +490,15 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleLayerWheelTransformation(e: WheelEvent): void {
|
private handleLayerWheelTransformation(e: WheelEvent): void {
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||||
const direction = e.deltaY < 0 ? 1 : -1;
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
|
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
|
||||||
} else {
|
} 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.height *= scaleFactor;
|
||||||
layer.x += (oldWidth - layer.width) / 2;
|
layer.x += (oldWidth - layer.width) / 2;
|
||||||
layer.y += (oldHeight - layer.height) / 2;
|
layer.y += (oldHeight - layer.height) / 2;
|
||||||
|
|
||||||
|
// Handle wheel scaling end for layers with blend area
|
||||||
|
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateGridBasedScaling(oldHeight: number, deltaY: number): number {
|
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;
|
const direction = deltaY > 0 ? -1 : 1;
|
||||||
let targetHeight;
|
let targetHeight;
|
||||||
|
|
||||||
@@ -474,6 +570,7 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
handleKeyDown(e: KeyboardEvent): void {
|
handleKeyDown(e: KeyboardEvent): void {
|
||||||
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
||||||
|
if (e.key === 'Meta') this.interaction.isMetaPressed = true;
|
||||||
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
|
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
|
||||||
if (e.key === 'Alt') {
|
if (e.key === 'Alt') {
|
||||||
this.interaction.isAltPressed = true;
|
this.interaction.isAltPressed = true;
|
||||||
@@ -492,11 +589,12 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
// 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;
|
let handled = true;
|
||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case 'z':
|
case 'z':
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.canvas.redo();
|
this.canvas.redo();
|
||||||
} else {
|
} else {
|
||||||
this.canvas.undo();
|
this.canvas.undo();
|
||||||
@@ -523,7 +621,7 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
const step = e.shiftKey ? 10 : 1;
|
const step = mods.shift ? 10 : 1;
|
||||||
let needsRender = false;
|
let needsRender = false;
|
||||||
|
|
||||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
// 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 {
|
handleKeyUp(e: KeyboardEvent): void {
|
||||||
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
|
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 === 'Shift') this.interaction.isShiftPressed = false;
|
||||||
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
||||||
if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false;
|
if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false;
|
||||||
@@ -577,6 +676,7 @@ export class CanvasInteractions {
|
|||||||
handleBlur(): void {
|
handleBlur(): void {
|
||||||
log.debug('Window lost focus, resetting key states.');
|
log.debug('Window lost focus, resetting key states.');
|
||||||
this.interaction.isCtrlPressed = false;
|
this.interaction.isCtrlPressed = false;
|
||||||
|
this.interaction.isMetaPressed = false;
|
||||||
this.interaction.isAltPressed = false;
|
this.interaction.isAltPressed = false;
|
||||||
this.interaction.isShiftPressed = false;
|
this.interaction.isShiftPressed = false;
|
||||||
this.interaction.isSPressed = false;
|
this.interaction.isSPressed = false;
|
||||||
@@ -602,6 +702,12 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateCursor(worldCoords: Point): void {
|
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);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
|
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
@@ -650,7 +756,9 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
prepareForDrag(layer: Layer, worldCoords: Point): void {
|
prepareForDrag(layer: Layer, worldCoords: Point): void {
|
||||||
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
// 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);
|
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||||
@@ -668,14 +776,13 @@ export class CanvasInteractions {
|
|||||||
this.interaction.dragStart = {...worldCoords};
|
this.interaction.dragStart = {...worldCoords};
|
||||||
}
|
}
|
||||||
|
|
||||||
startPanningOrClearSelection(e: MouseEvent): void {
|
startPanning(e: MouseEvent, clearSelection: boolean = true): void {
|
||||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
// Unified panning method - can optionally clear selection
|
||||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
if (clearSelection && !this.interaction.isCtrlPressed) {
|
||||||
if (!this.interaction.isCtrlPressed) {
|
|
||||||
this.canvas.canvasSelection.updateSelection([]);
|
this.canvas.canvasSelection.updateSelection([]);
|
||||||
}
|
}
|
||||||
this.interaction.mode = 'panning';
|
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 {
|
startCanvasResize(worldCoords: Point): void {
|
||||||
@@ -730,20 +837,18 @@ export class CanvasInteractions {
|
|||||||
this.canvas.saveState();
|
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 {
|
panViewport(e: MouseEvent): void {
|
||||||
const dx = e.clientX - this.interaction.panStart.x;
|
const dx = e.clientX - this.interaction.panStart.x;
|
||||||
const dy = e.clientY - this.interaction.panStart.y;
|
const dy = e.clientY - this.interaction.panStart.y;
|
||||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||||
|
|
||||||
|
// Update stroke overlay if mask tool is drawing during pan
|
||||||
|
if (this.canvas.maskTool.isDrawing) {
|
||||||
|
this.canvas.maskTool.handleViewportChange();
|
||||||
|
}
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.onViewportChange?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
@@ -805,7 +910,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
if (!o) return;
|
||||||
|
|
||||||
const handle = this.interaction.resizeHandle;
|
const handle = this.interaction.resizeHandle;
|
||||||
const anchor = this.interaction.resizeAnchor;
|
const anchor = this.interaction.resizeAnchor;
|
||||||
@@ -844,8 +949,15 @@ export class CanvasInteractions {
|
|||||||
// Rotate mouse delta into the layer's unrotated frame
|
// Rotate mouse delta into the layer's unrotated frame
|
||||||
const deltaX_world = mouseX_local - dragStartX_local;
|
const deltaX_world = mouseX_local - dragStartX_local;
|
||||||
const deltaY_world = mouseY_local - dragStartY_local;
|
const deltaY_world = mouseY_local - dragStartY_local;
|
||||||
const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
let mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
||||||
const mouseDeltaY_local = deltaY_world * cos - deltaX_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.
|
// Convert the on-screen mouse delta to an image-space delta.
|
||||||
const screenToImageScaleX = o.originalWidth / o.width;
|
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
|
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
|
// Apply the image-space delta to the appropriate edges of the crop bounds
|
||||||
|
const isFlippedH = layer.flipH;
|
||||||
|
const isFlippedV = layer.flipV;
|
||||||
|
|
||||||
if (handle?.includes('w')) {
|
if (handle?.includes('w')) {
|
||||||
newCropBounds.x += delta_image_x;
|
if (isFlippedH) newCropBounds.width += delta_image_x;
|
||||||
newCropBounds.width -= delta_image_x;
|
else {
|
||||||
|
newCropBounds.x += delta_image_x;
|
||||||
|
newCropBounds.width -= delta_image_x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (handle?.includes('e')) {
|
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')) {
|
if (handle?.includes('n')) {
|
||||||
newCropBounds.y += delta_image_y;
|
if (isFlippedV) newCropBounds.height += delta_image_y;
|
||||||
newCropBounds.height -= delta_image_y;
|
else {
|
||||||
|
newCropBounds.y += delta_image_y;
|
||||||
|
newCropBounds.height -= delta_image_y;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (handle?.includes('s')) {
|
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 (newCropBounds.width < 1) {
|
||||||
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1;
|
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1;
|
||||||
newCropBounds.width = 1;
|
newCropBounds.width = 1;
|
||||||
@@ -939,7 +1066,7 @@ export class CanvasInteractions {
|
|||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
|
|
||||||
const o = this.interaction.transformOrigin;
|
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 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);
|
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
||||||
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
|
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||||
import { createCanvas } from "./utils/CommonUtils.js";
|
import { createCanvas } from "./utils/CommonUtils.js";
|
||||||
|
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||||
import type { Canvas } from './Canvas';
|
import type { Canvas } from './Canvas';
|
||||||
import type { Layer } from './types';
|
import type { Layer } from './types';
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ export class CanvasLayersPanel {
|
|||||||
// Preload icons
|
// Preload icons
|
||||||
this.initializeIcons();
|
this.initializeIcons();
|
||||||
|
|
||||||
|
// Load CSS for layers panel
|
||||||
|
addStylesheet(getUrl('./css/layers_panel.css'));
|
||||||
|
|
||||||
log.info('CanvasLayersPanel initialized');
|
log.info('CanvasLayersPanel initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,23 +51,16 @@ export class CanvasLayersPanel {
|
|||||||
|
|
||||||
private createIconElement(toolName: string, size: number = 16): HTMLElement {
|
private createIconElement(toolName: string, size: number = 16): HTMLElement {
|
||||||
const iconContainer = document.createElement('div');
|
const iconContainer = document.createElement('div');
|
||||||
iconContainer.style.cssText = `
|
iconContainer.className = 'icon-container';
|
||||||
width: ${size}px;
|
iconContainer.style.width = `${size}px`;
|
||||||
height: ${size}px;
|
iconContainer.style.height = `${size}px`;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const icon = iconLoader.getIcon(toolName);
|
const icon = iconLoader.getIcon(toolName);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
if (icon instanceof HTMLImageElement) {
|
if (icon instanceof HTMLImageElement) {
|
||||||
const img = icon.cloneNode() as HTMLImageElement;
|
const img = icon.cloneNode() as HTMLImageElement;
|
||||||
img.style.cssText = `
|
img.style.width = `${size}px`;
|
||||||
width: ${size}px;
|
img.style.height = `${size}px`;
|
||||||
height: ${size}px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
`;
|
|
||||||
iconContainer.appendChild(img);
|
iconContainer.appendChild(img);
|
||||||
} else if (icon instanceof HTMLCanvasElement) {
|
} else if (icon instanceof HTMLCanvasElement) {
|
||||||
const { canvas, ctx } = createCanvas(size, size);
|
const { canvas, ctx } = createCanvas(size, size);
|
||||||
@@ -74,9 +71,9 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback text
|
// Fallback text
|
||||||
|
iconContainer.classList.add('fallback-text');
|
||||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||||
iconContainer.style.color = '#ffffff';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return iconContainer;
|
return iconContainer;
|
||||||
@@ -88,25 +85,16 @@ export class CanvasLayersPanel {
|
|||||||
} else {
|
} else {
|
||||||
// Create a "hidden" version of the visibility icon
|
// Create a "hidden" version of the visibility icon
|
||||||
const iconContainer = document.createElement('div');
|
const iconContainer = document.createElement('div');
|
||||||
iconContainer.style.cssText = `
|
iconContainer.className = 'icon-container visibility-hidden';
|
||||||
width: 16px;
|
iconContainer.style.width = '16px';
|
||||||
height: 16px;
|
iconContainer.style.height = '16px';
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0.5;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
if (icon instanceof HTMLImageElement) {
|
if (icon instanceof HTMLImageElement) {
|
||||||
const img = icon.cloneNode() as HTMLImageElement;
|
const img = icon.cloneNode() as HTMLImageElement;
|
||||||
img.style.cssText = `
|
img.style.width = '16px';
|
||||||
width: 16px;
|
img.style.height = '16px';
|
||||||
height: 16px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
opacity: 0.3;
|
|
||||||
`;
|
|
||||||
iconContainer.appendChild(img);
|
iconContainer.appendChild(img);
|
||||||
} else if (icon instanceof HTMLCanvasElement) {
|
} else if (icon instanceof HTMLCanvasElement) {
|
||||||
const { canvas, ctx } = createCanvas(16, 16);
|
const { canvas, ctx } = createCanvas(16, 16);
|
||||||
@@ -118,9 +106,9 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback
|
// Fallback
|
||||||
|
iconContainer.classList.add('fallback-text');
|
||||||
iconContainer.textContent = 'H';
|
iconContainer.textContent = 'H';
|
||||||
iconContainer.style.fontSize = '10px';
|
iconContainer.style.fontSize = '10px';
|
||||||
iconContainer.style.color = '#888888';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return iconContainer;
|
return iconContainer;
|
||||||
@@ -144,8 +132,6 @@ export class CanvasLayersPanel {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
|
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
|
||||||
|
|
||||||
this.injectStyles();
|
|
||||||
|
|
||||||
// Setup event listeners dla przycisków
|
// Setup event listeners dla przycisków
|
||||||
this.setupControlButtons();
|
this.setupControlButtons();
|
||||||
@@ -163,218 +149,10 @@ export class CanvasLayersPanel {
|
|||||||
return this.container;
|
return this.container;
|
||||||
}
|
}
|
||||||
|
|
||||||
injectStyles(): void {
|
|
||||||
const styleId = 'layers-panel-styles';
|
|
||||||
if (document.getElementById(styleId)) {
|
|
||||||
return; // Style już istnieją
|
|
||||||
}
|
|
||||||
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = styleId;
|
|
||||||
style.textContent = `
|
|
||||||
.layers-panel {
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: 1px solid #3a3a3a;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #ffffff;
|
|
||||||
user-select: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #3a3a3a;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-title {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn {
|
|
||||||
background: #3a3a3a;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn:hover {
|
|
||||||
background: #4a4a4a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn:active {
|
|
||||||
background: #5a5a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 4px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
position: relative;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row.selected {
|
|
||||||
background: #2d5aa0 !important;
|
|
||||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row.dragging {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.layer-thumbnail {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: transparent;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
|
||||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
|
||||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
|
||||||
background-size: 8px 8px;
|
|
||||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail canvas {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name.editing {
|
|
||||||
background: #4a4a4a;
|
|
||||||
border: 1px solid #6a6a6a;
|
|
||||||
outline: none;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name input {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 12px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-insertion-line {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: #4a7bc8;
|
|
||||||
border-radius: 1px;
|
|
||||||
z-index: 1000;
|
|
||||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-track {
|
|
||||||
background: #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-thumb {
|
|
||||||
background: #4a4a4a;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #5a5a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-visibility-toggle {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-visibility-toggle:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.head.appendChild(style);
|
|
||||||
log.debug('Styles injected');
|
|
||||||
}
|
|
||||||
|
|
||||||
setupControlButtons(): void {
|
setupControlButtons(): void {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
|
||||||
|
|
||||||
// Add delete icon to button
|
// Add delete icon to button
|
||||||
if (deleteBtn) {
|
if (deleteBtn) {
|
||||||
@@ -386,6 +164,9 @@ export class CanvasLayersPanel {
|
|||||||
log.info('Delete layer button clicked');
|
log.info('Delete layer button clicked');
|
||||||
this.deleteSelectedLayers();
|
this.deleteSelectedLayers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initial button state update
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLayers(): void {
|
renderLayers(): void {
|
||||||
@@ -495,6 +276,7 @@ export class CanvasLayersPanel {
|
|||||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||||
this.canvas.updateSelection(newSelection);
|
this.canvas.updateSelection(newSelection);
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -532,7 +314,8 @@ export class CanvasLayersPanel {
|
|||||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||||
|
|
||||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
|
|
||||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||||
}
|
}
|
||||||
@@ -751,12 +534,32 @@ export class CanvasLayersPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
|
||||||
|
*/
|
||||||
|
updateButtonStates(): void {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
|
||||||
|
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.disabled = !hasSelectedLayers;
|
||||||
|
deleteBtn.title = hasSelectedLayers
|
||||||
|
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
|
||||||
|
: 'No layers selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||||
*/
|
*/
|
||||||
onSelectionChanged(): void {
|
onSelectionChanged(): void {
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ export class CanvasRenderer {
|
|||||||
lastRenderTime: any;
|
lastRenderTime: any;
|
||||||
renderAnimationFrame: any;
|
renderAnimationFrame: any;
|
||||||
renderInterval: any;
|
renderInterval: any;
|
||||||
|
// Overlay used to preview in-progress mask strokes (separate from cursor overlay)
|
||||||
|
strokeOverlayCanvas!: HTMLCanvasElement;
|
||||||
|
strokeOverlayCtx!: CanvasRenderingContext2D;
|
||||||
constructor(canvas: any) {
|
constructor(canvas: any) {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.renderAnimationFrame = null;
|
this.renderAnimationFrame = null;
|
||||||
this.lastRenderTime = 0;
|
this.lastRenderTime = 0;
|
||||||
this.renderInterval = 1000 / 60;
|
this.renderInterval = 1000 / 60;
|
||||||
this.isDirty = false;
|
this.isDirty = false;
|
||||||
|
|
||||||
|
// Initialize overlay canvases
|
||||||
|
this.initOverlay();
|
||||||
|
this.initStrokeOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,9 +148,11 @@ export class CanvasRenderer {
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
|
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.canvas.maskTool.isActive) {
|
||||||
|
// In draw mask mode, use the previewOpacity value from the slider
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||||
} else {
|
} else {
|
||||||
|
// When not in draw mask mode, show mask at full opacity
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 1.0;
|
ctx.globalAlpha = 1.0;
|
||||||
}
|
}
|
||||||
@@ -205,6 +214,12 @@ export class CanvasRenderer {
|
|||||||
}
|
}
|
||||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
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
|
// Update Batch Preview UI positions
|
||||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
||||||
@@ -575,8 +590,10 @@ export class CanvasRenderer {
|
|||||||
// Draw line to rotation handle
|
// Draw line to rotation handle
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, -halfH);
|
const startY = layer.flipV ? halfH : -halfH;
|
||||||
ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom);
|
const endY = startY + (layer.flipV ? 1 : -1) * (20 / this.canvas.viewport.zoom);
|
||||||
|
ctx.moveTo(0, startY);
|
||||||
|
ctx.lineTo(0, endY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,21 +603,33 @@ export class CanvasRenderer {
|
|||||||
ctx.strokeStyle = '#000000';
|
ctx.strokeStyle = '#000000';
|
||||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
const centerX = layer.x + layer.width / 2;
|
||||||
|
const centerY = layer.y + layer.height / 2;
|
||||||
|
|
||||||
for (const key in handles) {
|
for (const key in handles) {
|
||||||
// Skip rotation handle in crop mode
|
// Skip rotation handle in crop mode
|
||||||
if (layer.cropMode && key === 'rot') continue;
|
if (layer.cropMode && key === 'rot') continue;
|
||||||
|
|
||||||
const point = handles[key];
|
const point = handles[key];
|
||||||
// The handle position is already in world space, we need it in the layer's rotated space
|
// The handle position is already in world space.
|
||||||
const localX = point.x - (layer.x + layer.width / 2);
|
// We need to convert it to the layer's local, un-rotated space.
|
||||||
const localY = point.y - (layer.y + layer.height / 2);
|
const dx = point.x - centerX;
|
||||||
|
const dy = point.y - centerY;
|
||||||
|
|
||||||
|
// "Un-rotate" the position to get it in the layer's local, un-rotated space
|
||||||
const rad = -layer.rotation * Math.PI / 180;
|
const rad = -layer.rotation * Math.PI / 180;
|
||||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
const cos = Math.cos(rad);
|
||||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
const sin = Math.sin(rad);
|
||||||
|
const localX = dx * cos - dy * sin;
|
||||||
|
const localY = dx * sin + dy * cos;
|
||||||
|
|
||||||
|
// 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.beginPath();
|
||||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
@@ -696,4 +725,290 @@ export class CanvasRenderer {
|
|||||||
padding: 8
|
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 = '0px';
|
||||||
|
this.strokeOverlayCanvas.style.top = '0px';
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => {
|
||||||
const newLayer: Omit<Layer, 'image'> & { imageId: string } = { ...layer, imageId: layer.imageId || '' };
|
const newLayer: Omit<Layer, 'image'> & { imageId: string } = { ...layer, imageId: layer.imageId || '' };
|
||||||
delete (newLayer as any).image;
|
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.image instanceof HTMLImageElement) {
|
||||||
if (layer.imageId) {
|
if (layer.imageId) {
|
||||||
@@ -459,12 +456,13 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
|
|
||||||
if (this.maskUndoStack.length > 0) {
|
if (this.maskUndoStack.length > 0) {
|
||||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
if (maskCtx) {
|
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
||||||
maskCtx.drawImage(prevState, 0, 0);
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
}
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,12 +475,13 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
const nextState = this.maskRedoStack.pop();
|
const nextState = this.maskRedoStack.pop();
|
||||||
if (nextState) {
|
if (nextState) {
|
||||||
this.maskUndoStack.push(nextState);
|
this.maskUndoStack.push(nextState);
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
if (maskCtx) {
|
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
||||||
maskCtx.drawImage(nextState, 0, 0);
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
}
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
}),
|
}),
|
||||||
$el("button.painter-button.icon-button", {
|
$el("button.painter-button.icon-button", {
|
||||||
textContent: "?",
|
textContent: "?",
|
||||||
title: "Show shortcuts",
|
|
||||||
onmouseenter: (e: MouseEvent) => {
|
onmouseenter: (e: MouseEvent) => {
|
||||||
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
||||||
showTooltip(e.target as HTMLElement, content);
|
showTooltip(e.target as HTMLElement, content);
|
||||||
@@ -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
|
// Tooltip logic
|
||||||
switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
|
switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
|
||||||
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
|
const tooltipContent = getCurrentTooltipContent();
|
||||||
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
|
||||||
showTooltip(switchEl, tooltipContent);
|
showTooltip(switchEl, tooltipContent);
|
||||||
});
|
});
|
||||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||||
@@ -231,6 +244,9 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
"🗂️",
|
"🗂️",
|
||||||
"📋"
|
"📋"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update tooltip content immediately after state change
|
||||||
|
updateTooltipIfVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
@@ -499,6 +515,10 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
|
|
||||||
canvas.layers[selectedLayerIndex] = newLayer;
|
canvas.layers[selectedLayerIndex] = newLayer;
|
||||||
canvas.canvasSelection.updateSelection([newLayer]);
|
canvas.canvasSelection.updateSelection([newLayer]);
|
||||||
|
|
||||||
|
// Invalidate processed image cache when layer image changes (matting)
|
||||||
|
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
|
||||||
|
|
||||||
canvas.render();
|
canvas.render();
|
||||||
canvas.saveState();
|
canvas.saveState();
|
||||||
showSuccessNotification("Background removed successfully!");
|
showSuccessNotification("Background removed successfully!");
|
||||||
@@ -534,7 +554,8 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
$el("div.painter-button-group", {id: "mask-controls"}, [
|
$el("div.painter-button-group", {id: "mask-controls"}, [
|
||||||
$el("label.clipboard-switch.mask-switch", {
|
$el("label.clipboard-switch.mask-switch", {
|
||||||
id: `toggle-mask-switch-${node.id}`,
|
id: `toggle-mask-switch-${node.id}`,
|
||||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
|
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" },
|
||||||
|
title: "Toggle mask overlay visibility on canvas (mask still affects output when disabled)"
|
||||||
}, [
|
}, [
|
||||||
$el("input", {
|
$el("input", {
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
@@ -619,6 +640,24 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
setTimeout(() => canvas.render(), 0);
|
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("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
||||||
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
|
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
|
||||||
$el("input", {
|
$el("input", {
|
||||||
@@ -990,7 +1029,9 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
|
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
|
||||||
|
|
||||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
if (node.addDOMWidget) {
|
||||||
|
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
|
}
|
||||||
|
|
||||||
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`) as HTMLButtonElement;
|
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`) as HTMLButtonElement;
|
||||||
let backdrop: HTMLDivElement | null = null;
|
let backdrop: HTMLDivElement | null = null;
|
||||||
@@ -1102,7 +1143,12 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
if (!(window as any).canvasExecutionStates) {
|
if (!(window as any).canvasExecutionStates) {
|
||||||
(window as any).canvasExecutionStates = new Map<string, any>();
|
(window as any).canvasExecutionStates = new Map<string, any>();
|
||||||
}
|
}
|
||||||
(node as any).canvasWidget = canvas;
|
|
||||||
|
// Store the entire widget object, not just the canvas
|
||||||
|
(node as any).canvasWidget = {
|
||||||
|
canvas: canvas,
|
||||||
|
panel: controlPanel
|
||||||
|
};
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canvas.loadInitialState();
|
canvas.loadInitialState();
|
||||||
@@ -1124,7 +1170,7 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
canvas.setPreviewVisibility(value);
|
canvas.setPreviewVisibility(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((node as any).graph && (node as any).graph.canvas) {
|
if ((node as any).graph && (node as any).graph.canvas && node.setDirtyCanvas) {
|
||||||
node.setDirtyCanvas(true, true);
|
node.setDirtyCanvas(true, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1144,7 +1190,7 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
const canvasNodeInstances = new Map<number, CanvasWidget>();
|
const canvasNodeInstances = new Map<number, CanvasWidget>();
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "Comfy.CanvasNode",
|
name: "Comfy.LayerForgeNode",
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
addStylesheet(getUrl('./css/canvas_view.css'));
|
addStylesheet(getUrl('./css/canvas_view.css'));
|
||||||
@@ -1183,7 +1229,7 @@ app.registerExtension({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
|
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
|
||||||
if (nodeType.comfyClass === "CanvasNode") {
|
if (nodeType.comfyClass === "LayerForgeNode") {
|
||||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
|
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
|
||||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||||
@@ -1216,10 +1262,156 @@ app.registerExtension({
|
|||||||
const canvasWidget = await createCanvasWidget(this, null, app);
|
const canvasWidget = await createCanvasWidget(this, null, app);
|
||||||
canvasNodeInstances.set(this.id, canvasWidget);
|
canvasNodeInstances.set(this.id, canvasWidget);
|
||||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||||
|
|
||||||
|
// Store the canvas widget on the node
|
||||||
|
(this as any).canvasWidget = canvasWidget;
|
||||||
|
|
||||||
|
// Check if there are already connected inputs
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setDirtyCanvas(true, true);
|
if (this.inputs && this.inputs.length > 0) {
|
||||||
}, 100);
|
// Check if input_image (index 0) is connected
|
||||||
|
if (this.inputs[0] && this.inputs[0].link) {
|
||||||
|
log.info("Input image already connected on node creation, checking for data...");
|
||||||
|
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||||
|
canvasWidget.canvas.inputDataLoaded = false;
|
||||||
|
// Only allow images on init; mask should load only on mask connect or execution
|
||||||
|
canvasWidget.canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "init_image_connected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.setDirtyCanvas) {
|
||||||
|
this.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add onConnectionsChange handler to detect when inputs are connected
|
||||||
|
nodeType.prototype.onConnectionsChange = function (this: ComfyNode, type: number, index: number, connected: boolean, link_info: any) {
|
||||||
|
log.info(`onConnectionsChange called: type=${type}, index=${index}, connected=${connected}`, link_info);
|
||||||
|
|
||||||
|
// Check if this is an input connection (type 1 = INPUT)
|
||||||
|
if (type === 1) {
|
||||||
|
// Get the canvas widget - it might be in different places
|
||||||
|
const canvasWidget = (this as any).canvasWidget;
|
||||||
|
const canvas = canvasWidget?.canvas || canvasWidget;
|
||||||
|
|
||||||
|
if (!canvas || !canvas.canvasIO) {
|
||||||
|
log.warn("Canvas not ready in onConnectionsChange, scheduling retry...");
|
||||||
|
// Retry multiple times with increasing delays
|
||||||
|
const retryDelays = [500, 1000, 2000];
|
||||||
|
let retryCount = 0;
|
||||||
|
|
||||||
|
const tryAgain = () => {
|
||||||
|
const retryCanvas = (this as any).canvasWidget?.canvas || (this as any).canvasWidget;
|
||||||
|
if (retryCanvas && retryCanvas.canvasIO) {
|
||||||
|
log.info("Canvas now ready, checking for input data...");
|
||||||
|
if (connected) {
|
||||||
|
retryCanvas.inputDataLoaded = false;
|
||||||
|
// Respect which input triggered the connection:
|
||||||
|
const opts = (index === 1)
|
||||||
|
? { allowImage: false, allowMask: true, reason: "mask_connect" }
|
||||||
|
: { allowImage: true, allowMask: false, reason: "image_connect" };
|
||||||
|
retryCanvas.canvasIO.checkForInputData(opts);
|
||||||
|
}
|
||||||
|
} else if (retryCount < retryDelays.length) {
|
||||||
|
log.warn(`Canvas still not ready, retry ${retryCount + 1}/${retryDelays.length}...`);
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
} else {
|
||||||
|
log.error("Canvas failed to initialize after multiple retries");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input_image connection (index 0)
|
||||||
|
if (index === 0) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input image connected, marking for data check...");
|
||||||
|
// Reset the input data loaded flag to allow loading the new connection
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
// Also reset the last loaded image source and link ID to allow the new image
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
// Mark that we have a pending input connection
|
||||||
|
canvas.hasPendingInputConnection = true;
|
||||||
|
|
||||||
|
// If mask input is not connected and a mask was auto-applied from input_mask before, clear it now
|
||||||
|
if (!(this.inputs && this.inputs[1] && this.inputs[1].link)) {
|
||||||
|
if ((canvas as any).maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
canvas.maskTool.clear();
|
||||||
|
canvas.render();
|
||||||
|
(canvas as any).maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask because input_image connected without input_mask");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after connection...");
|
||||||
|
// Only load images here; masks should not auto-load on image connect
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "image_connect" });
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
log.info("Input image disconnected");
|
||||||
|
canvas.hasPendingInputConnection = false;
|
||||||
|
// Reset when disconnected so a new connection can load
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input_mask connection (index 1)
|
||||||
|
if (index === 1) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input mask connected");
|
||||||
|
|
||||||
|
// DON'T clear existing mask when connecting a new input
|
||||||
|
// Reset the loaded mask link ID to allow loading from the new connection
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
|
||||||
|
// Mark that we have a pending mask connection
|
||||||
|
canvas.hasPendingMaskConnection = true;
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after mask connection...");
|
||||||
|
// Only load mask here if it's immediately available from the connected node
|
||||||
|
// Don't load stale masks from backend storage
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: false, allowMask: true, reason: "mask_connect" });
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
log.info("Input mask disconnected");
|
||||||
|
canvas.hasPendingMaskConnection = false;
|
||||||
|
// If the current mask came from input_mask, clear it to avoid affecting images when mask is not connected
|
||||||
|
if ((canvas as any).maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
(canvas as any).maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask due to mask input disconnection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add onExecuted handler to check for input data after workflow execution
|
||||||
|
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||||
|
nodeType.prototype.onExecuted = function (this: ComfyNode, message: any) {
|
||||||
|
log.info("Node executed, checking for input data...");
|
||||||
|
|
||||||
|
const canvas = (this as any).canvasWidget?.canvas || (this as any).canvasWidget;
|
||||||
|
if (canvas && canvas.canvasIO) {
|
||||||
|
// Don't reset inputDataLoaded - just check for new data
|
||||||
|
// On execution we allow both image and mask to load
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: "execution" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call original if it exists
|
||||||
|
if (originalOnExecuted) {
|
||||||
|
originalOnExecuted.apply(this, arguments as any);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoved = nodeType.prototype.onRemoved;
|
const onRemoved = nodeType.prototype.onRemoved;
|
||||||
|
|||||||
@@ -507,7 +507,6 @@ export class MaskEditorIntegration {
|
|||||||
maskSize: {width: bounds.width, height: bounds.height}
|
maskSize: {width: bounds.width, height: bounds.height}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the chunk system instead of direct canvas manipulation
|
|
||||||
this.maskTool.setMask(maskAsImage);
|
this.maskTool.setMask(maskAsImage);
|
||||||
|
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
|
|||||||
287
src/MaskTool.ts
287
src/MaskTool.ts
@@ -21,9 +21,10 @@ interface MaskChunk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MaskTool {
|
export class MaskTool {
|
||||||
private brushHardness: number;
|
private _brushHardness: number;
|
||||||
private brushSize: number;
|
public brushSize: number;
|
||||||
private brushStrength: number;
|
private _brushStrength: number;
|
||||||
|
private _previewOpacity: number;
|
||||||
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
|
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
|
||||||
public isActive: boolean;
|
public isActive: boolean;
|
||||||
public isDrawing: boolean;
|
public isDrawing: boolean;
|
||||||
@@ -31,6 +32,9 @@ export class MaskTool {
|
|||||||
private lastPosition: Point | null;
|
private lastPosition: Point | null;
|
||||||
private mainCanvas: HTMLCanvasElement;
|
private mainCanvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
// Track strokes during drawing for efficient overlay updates
|
||||||
|
private currentStrokePoints: Point[] = [];
|
||||||
|
|
||||||
// Chunked mask system
|
// Chunked mask system
|
||||||
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
|
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
|
||||||
private chunkSize: number;
|
private chunkSize: number;
|
||||||
@@ -72,6 +76,9 @@ export class MaskTool {
|
|||||||
this.mainCanvas = canvasInstance.canvas;
|
this.mainCanvas = canvasInstance.canvas;
|
||||||
this.onStateChange = callbacks.onStateChange || null;
|
this.onStateChange = callbacks.onStateChange || null;
|
||||||
|
|
||||||
|
// Initialize stroke tracking for overlay drawing
|
||||||
|
this.currentStrokePoints = [];
|
||||||
|
|
||||||
// Initialize chunked mask system
|
// Initialize chunked mask system
|
||||||
this.maskChunks = new Map();
|
this.maskChunks = new Map();
|
||||||
this.chunkSize = 512;
|
this.chunkSize = 512;
|
||||||
@@ -96,8 +103,9 @@ export class MaskTool {
|
|||||||
this.isOverlayVisible = true;
|
this.isOverlayVisible = true;
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
this.brushSize = 20;
|
this.brushSize = 20;
|
||||||
this.brushStrength = 0.5;
|
this._brushStrength = 0.5;
|
||||||
this.brushHardness = 0.5;
|
this._brushHardness = 0.5;
|
||||||
|
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.lastPosition = null;
|
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 {
|
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 {
|
initMaskCanvas(): void {
|
||||||
@@ -867,7 +898,7 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBrushStrength(strength: number): void {
|
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 {
|
handleMouseDown(worldCoords: Point, viewCoords: Point): void {
|
||||||
@@ -875,10 +906,12 @@ export class MaskTool {
|
|||||||
this.isDrawing = true;
|
this.isDrawing = true;
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
|
|
||||||
// Activate chunks around the drawing position for performance
|
// Initialize stroke tracking for live preview
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints = [worldCoords];
|
||||||
|
|
||||||
|
// Clear any previous stroke overlay
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
this.draw(worldCoords);
|
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,16 +921,83 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
if (!this.isActive || !this.isDrawing) return;
|
if (!this.isActive || !this.isDrawing) return;
|
||||||
|
|
||||||
// Dynamically update active chunks as user moves while drawing
|
// Add point to stroke tracking
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
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;
|
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 {
|
handleMouseLeave(): void {
|
||||||
this.previewVisible = false;
|
this.previewVisible = false;
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
|
// Clear overlay canvases when mouse leaves
|
||||||
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseEnter(): void {
|
handleMouseEnter(): void {
|
||||||
@@ -908,11 +1008,18 @@ export class MaskTool {
|
|||||||
if (!this.isActive) return;
|
if (!this.isActive) return;
|
||||||
if (this.isDrawing) {
|
if (this.isDrawing) {
|
||||||
this.isDrawing = false;
|
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.lastPosition = null;
|
||||||
this.currentDrawingChunk = null;
|
this.currentDrawingChunk = null;
|
||||||
|
|
||||||
// After drawing is complete, update active canvas to show all chunks
|
// 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.completeMaskOperation();
|
||||||
this.drawBrushPreview(viewCoords);
|
this.drawBrushPreview(viewCoords);
|
||||||
@@ -932,6 +1039,44 @@ export class MaskTool {
|
|||||||
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
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
|
* Draws a line between two world coordinates on the appropriate chunks
|
||||||
*/
|
*/
|
||||||
@@ -982,15 +1127,15 @@ export class MaskTool {
|
|||||||
|
|
||||||
const gradientRadius = this.brushSize / 2;
|
const gradientRadius = this.brushSize / 2;
|
||||||
|
|
||||||
if (this.brushHardness === 1) {
|
if (this._brushHardness === 1) {
|
||||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||||
} else {
|
} else {
|
||||||
const innerRadius = gradientRadius * this.brushHardness;
|
const innerRadius = gradientRadius * this._brushHardness;
|
||||||
const gradient = chunk.ctx.createRadialGradient(
|
const gradient = chunk.ctx.createRadialGradient(
|
||||||
endLocal.x, endLocal.y, innerRadius,
|
endLocal.x, endLocal.y, innerRadius,
|
||||||
endLocal.x, endLocal.y, gradientRadius
|
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)`);
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
chunk.ctx.strokeStyle = gradient;
|
chunk.ctx.strokeStyle = gradient;
|
||||||
}
|
}
|
||||||
@@ -1029,29 +1174,17 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates active canvas when drawing affects chunks with throttling to prevent lag
|
* Updates active canvas when drawing affects chunks
|
||||||
* During drawing, only updates the affected active chunks for performance
|
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||||
*/
|
*/
|
||||||
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
|
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
|
||||||
// Calculate which chunks were affected by this drawing operation
|
// This method is now simplified - we only update after drawing is complete
|
||||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
if (!this.isDrawing) {
|
||||||
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 {
|
|
||||||
// Not drawing - do full update to show all chunks
|
// Not drawing - do full update to show all chunks
|
||||||
this.updateActiveMaskCanvas(true);
|
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 {
|
drawBrushPreview(viewCoords: Point): void {
|
||||||
if (!this.previewVisible || this.isDrawing) {
|
if (!this.previewVisible || this.isDrawing) {
|
||||||
this.clearPreview();
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearPreview();
|
// Use overlay canvas instead of preview canvas for brush cursor
|
||||||
const zoom = this.canvasInstance.viewport.zoom;
|
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||||
const radius = (this.brushSize / 2) * zoom;
|
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPreview(): void {
|
clearPreview(): void {
|
||||||
@@ -1548,6 +1674,27 @@ export class MaskTool {
|
|||||||
log.info("Cleared all mask data from all chunks");
|
log.info("Cleared all mask data from all chunks");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all chunks and restores mask from saved state
|
||||||
|
* This is used during undo/redo operations to ensure clean state restoration
|
||||||
|
*/
|
||||||
|
restoreMaskFromSavedState(savedMaskCanvas: HTMLCanvasElement): void {
|
||||||
|
// First, clear ALL chunks to ensure no leftover data
|
||||||
|
this.clearAllMaskChunks();
|
||||||
|
|
||||||
|
// Now apply the saved mask state to chunks
|
||||||
|
if (savedMaskCanvas.width > 0 && savedMaskCanvas.height > 0) {
|
||||||
|
// Apply the saved mask to the chunk system at the correct position
|
||||||
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
|
this.applyMaskCanvasToChunks(savedMaskCanvas, this.x, this.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the active mask canvas to show the restored state
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
|
||||||
|
log.debug("Restored mask from saved state with clean chunk system");
|
||||||
|
}
|
||||||
|
|
||||||
getMask(): HTMLCanvasElement {
|
getMask(): HTMLCanvasElement {
|
||||||
// Return the current active mask canvas which shows all chunks
|
// Return the current active mask canvas which shows all chunks
|
||||||
// Only update if there are pending changes to avoid unnecessary redraws
|
// Only update if there are pending changes to avoid unnecessary redraws
|
||||||
@@ -1667,15 +1814,47 @@ export class MaskTool {
|
|||||||
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMask(image: HTMLImageElement): void {
|
setMask(image: HTMLImageElement, isFromInputMask: boolean = false): void {
|
||||||
// Clear existing mask chunks in the output area first
|
|
||||||
const bounds = this.canvasInstance.outputAreaBounds;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height);
|
|
||||||
|
|
||||||
// Add the new mask using the chunk system
|
if (isFromInputMask) {
|
||||||
this.addMask(image);
|
// For INPUT MASK - process black background to transparent using luminance
|
||||||
|
// Center like input images
|
||||||
log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
const centerX = bounds.x + (bounds.width - image.width) / 2;
|
||||||
|
const centerY = bounds.y + (bounds.height - image.height) / 2;
|
||||||
|
|
||||||
|
// Prepare mask where alpha = luminance (white = applied, black = transparent)
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create mask processing context");
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
|
const imgData = ctx.getImageData(0, 0, image.width, image.height);
|
||||||
|
const data = imgData.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||||
|
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
|
data[i] = 255; // force white color (color channels ignored downstream)
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = lum; // alpha encodes mask strength: white -> strong, black -> 0
|
||||||
|
}
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
|
// Clear target area and apply to chunked system at centered position
|
||||||
|
this.clearMaskInArea(centerX, centerY, image.width, image.height);
|
||||||
|
this.applyMaskCanvasToChunks(maskCanvas, centerX, centerY);
|
||||||
|
|
||||||
|
// Refresh state and UI
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
|
this.canvasInstance.render();
|
||||||
|
log.info(`MaskTool set INPUT MASK at centered position (${centerX}, ${centerY}) using luminance as alpha`);
|
||||||
|
} else {
|
||||||
|
// For SAM Detector and other sources - just clear and add without processing
|
||||||
|
this.clearMaskInArea(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||||
|
this.addMask(image);
|
||||||
|
log.info(`MaskTool set mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.j
|
|||||||
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
||||||
import { convertToImage } from "./utils/ImageUtils.js";
|
import { convertToImage } from "./utils/ImageUtils.js";
|
||||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||||
|
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
|
||||||
import type { ComfyNode } from './types';
|
import type { ComfyNode } from './types';
|
||||||
|
|
||||||
const log = createModuleLogger('SAMDetectorIntegration');
|
const log = createModuleLogger('SAMDetectorIntegration');
|
||||||
@@ -281,36 +282,61 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
|||||||
log.debug("Attempting to reload SAM result image");
|
log.debug("Attempting to reload SAM result image");
|
||||||
const originalSrc = resultImage.src;
|
const originalSrc = resultImage.src;
|
||||||
|
|
||||||
// Add cache-busting parameter to force fresh load
|
// Check if it's a data URL (base64) - don't add parameters to data URLs
|
||||||
const url = new URL(originalSrc);
|
if (originalSrc.startsWith('data:')) {
|
||||||
url.searchParams.set('_t', Date.now().toString());
|
log.debug("Image is a data URL, skipping reload with parameters");
|
||||||
|
// For data URLs, just ensure the image is loaded
|
||||||
await new Promise((resolve, reject) => {
|
if (!resultImage.complete || resultImage.naturalWidth === 0) {
|
||||||
const img = new Image();
|
await new Promise((resolve, reject) => {
|
||||||
img.crossOrigin = "anonymous";
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
// Copy the loaded image data to the original image
|
resultImage.width = img.width;
|
||||||
resultImage.src = img.src;
|
resultImage.height = img.height;
|
||||||
resultImage.width = img.width;
|
log.debug("Data URL image loaded successfully", {
|
||||||
resultImage.height = img.height;
|
width: img.width,
|
||||||
log.debug("SAM result image reloaded successfully", {
|
height: img.height
|
||||||
width: img.width,
|
});
|
||||||
height: img.height,
|
resolve(img);
|
||||||
originalSrc: originalSrc,
|
};
|
||||||
newSrc: img.src
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to load data URL image", error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = originalSrc; // Use original src without modifications
|
||||||
});
|
});
|
||||||
resolve(img);
|
}
|
||||||
};
|
} else {
|
||||||
img.onerror = (error) => {
|
// For regular URLs, add cache-busting parameter
|
||||||
log.error("Failed to reload SAM result image", {
|
const url = new URL(originalSrc);
|
||||||
originalSrc: originalSrc,
|
url.searchParams.set('_t', Date.now().toString());
|
||||||
newSrc: url.toString(),
|
|
||||||
error: error
|
await new Promise((resolve, reject) => {
|
||||||
});
|
const img = new Image();
|
||||||
reject(error);
|
img.crossOrigin = "anonymous";
|
||||||
};
|
img.onload = () => {
|
||||||
img.src = url.toString();
|
// Copy the loaded image data to the original image
|
||||||
});
|
resultImage.src = img.src;
|
||||||
|
resultImage.width = img.width;
|
||||||
|
resultImage.height = img.height;
|
||||||
|
log.debug("SAM result image reloaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: img.src
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to reload SAM result image", {
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: url.toString(),
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to load image from SAM Detector.", error);
|
log.error("Failed to load image from SAM Detector.", error);
|
||||||
@@ -332,32 +358,43 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
|||||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||||
log.debug("Checking canvas and maskTool availability", {
|
log.debug("Checking canvas and maskTool availability", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasCanvasProperty: !!canvas.canvas,
|
||||||
|
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
|
||||||
hasMaskTool: !!canvas.maskTool,
|
hasMaskTool: !!canvas.maskTool,
|
||||||
|
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
|
||||||
maskToolType: typeof canvas.maskTool,
|
maskToolType: typeof canvas.maskTool,
|
||||||
|
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas)
|
canvasKeys: Object.keys(canvas)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!canvas.maskTool) {
|
// Get the actual Canvas object and its maskTool
|
||||||
|
const actualCanvas = canvas.canvas || canvas;
|
||||||
|
const maskTool = actualCanvas.maskTool;
|
||||||
|
|
||||||
|
if (!maskTool) {
|
||||||
log.error("MaskTool is not available. Canvas state:", {
|
log.error("MaskTool is not available. Canvas state:", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasActualCanvas: !!actualCanvas,
|
||||||
canvasConstructor: canvas.constructor.name,
|
canvasConstructor: canvas.constructor.name,
|
||||||
|
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas),
|
canvasKeys: Object.keys(canvas),
|
||||||
maskToolValue: canvas.maskTool
|
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
|
||||||
|
maskToolValue: maskTool
|
||||||
});
|
});
|
||||||
throw new Error("Mask tool not available or not initialized");
|
throw new Error("Mask tool not available or not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug("Applying SAM mask to canvas using addMask method");
|
log.debug("Applying SAM mask to canvas using setMask method");
|
||||||
|
|
||||||
// Use the addMask method which overlays on existing mask without clearing it
|
// Use the setMask method which clears existing mask and sets new one
|
||||||
canvas.maskTool.addMask(maskAsImage);
|
maskTool.setMask(maskAsImage);
|
||||||
|
|
||||||
// Update canvas and save state (same as MaskEditorIntegration)
|
// Update canvas and save state (same as MaskEditorIntegration)
|
||||||
canvas.render();
|
actualCanvas.render();
|
||||||
canvas.saveState();
|
actualCanvas.saveState();
|
||||||
|
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
await updateNodePreview(canvas, node, true);
|
await updateNodePreview(actualCanvas, node, true);
|
||||||
|
|
||||||
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||||
|
|
||||||
@@ -376,6 +413,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
|
// Function to setup SAM Detector hook in menu options
|
||||||
export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
||||||
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||||
@@ -395,22 +435,67 @@ export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
|||||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||||
|
|
||||||
// Automatically send canvas to clipspace and start monitoring
|
// Automatically send canvas to clipspace and start monitoring
|
||||||
if ((node as any).canvasWidget && (node as any).canvasWidget.canvas) {
|
if ((node as any).canvasWidget) {
|
||||||
const canvas = (node as any).canvasWidget; // canvasWidget IS the Canvas object
|
const canvasWidget = (node as any).canvasWidget;
|
||||||
|
const canvas = canvasWidget.canvas || canvasWidget; // Get actual Canvas object
|
||||||
|
|
||||||
// Use ImageUploadUtils to upload canvas
|
// Use ImageUploadUtils to upload canvas and get server URL (Impact Pack compatibility)
|
||||||
const uploadResult = await uploadCanvasAsImage(canvas, {
|
const uploadResult = await uploadCanvasAsImage(canvas, {
|
||||||
filenamePrefix: 'layerforge-sam',
|
filenamePrefix: 'layerforge-sam',
|
||||||
nodeId: node.id
|
nodeId: node.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.debug("Uploaded canvas for SAM Detector", {
|
||||||
|
filename: uploadResult.filename,
|
||||||
|
imageUrl: uploadResult.imageUrl,
|
||||||
|
width: uploadResult.imageElement.width,
|
||||||
|
height: uploadResult.imageElement.height
|
||||||
|
});
|
||||||
|
|
||||||
// Set the image to the node for clipspace
|
// Set the image to the node for clipspace
|
||||||
node.imgs = [uploadResult.imageElement];
|
node.imgs = [uploadResult.imageElement];
|
||||||
(node as any).clipspaceImg = 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
|
// Copy to ComfyUI clipspace
|
||||||
ComfyApp.copyToClipspace(node);
|
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
|
// Start monitoring for SAM Detector results
|
||||||
startSAMDetectorMonitoring(node);
|
startSAMDetectorMonitoring(node);
|
||||||
|
|
||||||
|
|||||||
170
src/css/blend_mode_menu.css
Normal file
170
src/css/blend_mode_menu.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/* Blend Mode Menu Styles */
|
||||||
|
#blend-mode-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 10000;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-title-bar {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-title-text {
|
||||||
|
flex: 1;
|
||||||
|
cursor: move;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button:focus {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-content {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-container {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-label {
|
||||||
|
color: white;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-container {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option {
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option:hover {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option.active {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
display: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 550;
|
font-weight: 550;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -346,6 +346,11 @@
|
|||||||
background-color: #4a4a4a !important;
|
background-color: #4a4a4a !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clipboard-switch.disabled .switch-labels {
|
||||||
|
color: #777 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.painter-separator {
|
.painter-separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -633,7 +638,7 @@
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
z-index: 111;
|
z-index: 999999;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
230
src/css/layers_panel.css
Normal file
230
src/css/layers_panel.css
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/* Layers Panel Styles */
|
||||||
|
.layers-panel {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:hover {
|
||||||
|
background: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:active {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:disabled {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #666666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:disabled:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.selected {
|
||||||
|
background: #2d5aa0 !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name.editing {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border: 1px solid #6a6a6a;
|
||||||
|
outline: none;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-insertion-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #4a7bc8;
|
||||||
|
border-radius: 1px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-track {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility-toggle {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon container styles */
|
||||||
|
.layers-panel .icon-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.visibility-hidden {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.visibility-hidden img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.fallback-text {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
31
src/types.ts
31
src/types.ts
@@ -1,6 +1,14 @@
|
|||||||
import type { Canvas as CanvasClass } from './Canvas';
|
import type { Canvas as CanvasClass } from './Canvas';
|
||||||
import type { CanvasLayers } from './CanvasLayers';
|
import type { CanvasLayers } from './CanvasLayers';
|
||||||
|
|
||||||
|
export interface ComfyWidget {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
value: any;
|
||||||
|
callback?: (value: any) => void;
|
||||||
|
options?: any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Layer {
|
export interface Layer {
|
||||||
id: string;
|
id: string;
|
||||||
image: HTMLImageElement;
|
image: HTMLImageElement;
|
||||||
@@ -28,21 +36,20 @@ export interface Layer {
|
|||||||
width: number; // szerokość widocznego obszaru
|
width: number; // szerokość widocznego obszaru
|
||||||
height: number; // wysokość 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 {
|
export interface ComfyNode {
|
||||||
id: number;
|
id: number;
|
||||||
|
type: string;
|
||||||
|
widgets: ComfyWidget[];
|
||||||
imgs?: HTMLImageElement[];
|
imgs?: HTMLImageElement[];
|
||||||
widgets: any[];
|
size?: [number, number];
|
||||||
size: [number, number];
|
|
||||||
graph: any;
|
|
||||||
canvasWidget?: any;
|
|
||||||
onResize?: () => void;
|
onResize?: () => void;
|
||||||
addDOMWidget: (name: string, type: string, element: HTMLElement, options?: any) => any;
|
setDirtyCanvas?: (dirty: boolean, propagate: boolean) => void;
|
||||||
addWidget: (type: string, name: string, value: any, callback?: (value: any) => void, options?: any) => any;
|
graph?: any;
|
||||||
setDirtyCanvas: (force: boolean, dirty: boolean) => void;
|
onRemoved?: () => void;
|
||||||
|
addDOMWidget?: (name: string, type: string, element: HTMLElement) => void;
|
||||||
|
inputs?: Array<{ link: any }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -81,8 +88,14 @@ export interface Canvas {
|
|||||||
imageCache: any;
|
imageCache: any;
|
||||||
dataInitialized: boolean;
|
dataInitialized: boolean;
|
||||||
pendingDataCheck: number | null;
|
pendingDataCheck: number | null;
|
||||||
|
pendingInputDataCheck: number | null;
|
||||||
pendingBatchContext: any;
|
pendingBatchContext: any;
|
||||||
canvasLayers: any;
|
canvasLayers: any;
|
||||||
|
inputDataLoaded: boolean;
|
||||||
|
lastLoadedLinkId: any;
|
||||||
|
lastLoadedMaskLinkId: any;
|
||||||
|
lastLoadedImageSrc?: string;
|
||||||
|
outputAreaBounds: OutputAreaBounds;
|
||||||
saveState: () => void;
|
saveState: () => void;
|
||||||
render: () => void;
|
render: () => void;
|
||||||
updateSelection: (layers: Layer[]) => void;
|
updateSelection: (layers: Layer[]) => void;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {createModuleLogger} from "./LoggerUtils.js";
|
import {createModuleLogger} from "./LoggerUtils.js";
|
||||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
||||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||||
|
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import {api} from "../../../scripts/api.js";
|
import {api} from "../../../scripts/api.js";
|
||||||
@@ -56,7 +57,13 @@ export class ClipboardManager {
|
|||||||
*/
|
*/
|
||||||
tryClipspacePaste = withErrorHandling(async (addMode: AddMode): Promise<boolean> => {
|
tryClipspacePaste = withErrorHandling(async (addMode: AddMode): Promise<boolean> => {
|
||||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
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) {
|
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||||
const clipspaceImage = this.canvas.node.imgs[0];
|
const clipspaceImage = this.canvas.node.imgs[0];
|
||||||
|
|||||||
114
src/utils/ClipspaceUtils.ts
Normal file
114
src/utils/ClipspaceUtils.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { createModuleLogger } from "./LoggerUtils.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import { ComfyApp } from "../../../scripts/app.js";
|
||||||
|
|
||||||
|
const log = createModuleLogger('ClipspaceUtils');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
|
||||||
|
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
|
||||||
|
*/
|
||||||
|
export function validateAndFixClipspace(): boolean {
|
||||||
|
log.debug("Validating and fixing clipspace structure");
|
||||||
|
|
||||||
|
// Check if clipspace exists
|
||||||
|
if (!ComfyApp.clipspace) {
|
||||||
|
log.debug("ComfyUI clipspace is not available");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate clipspace structure
|
||||||
|
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
|
||||||
|
log.debug("ComfyUI clipspace has no images");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Current clipspace state:", {
|
||||||
|
hasImgs: !!ComfyApp.clipspace.imgs,
|
||||||
|
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||||
|
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||||
|
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||||
|
img_paste_mode: ComfyApp.clipspace.img_paste_mode
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure required indices are set
|
||||||
|
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
|
||||||
|
ComfyApp.clipspace.selectedIndex = 0;
|
||||||
|
log.debug("Fixed clipspace selectedIndex to 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = 0;
|
||||||
|
log.debug("Fixed clipspace combinedIndex to 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ComfyApp.clipspace.img_paste_mode) {
|
||||||
|
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||||
|
log.debug("Fixed clipspace img_paste_mode to 'selected'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure indices are within bounds
|
||||||
|
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
|
||||||
|
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
|
||||||
|
ComfyApp.clipspace.selectedIndex = maxIndex;
|
||||||
|
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
|
||||||
|
}
|
||||||
|
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = maxIndex;
|
||||||
|
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the image at combinedIndex exists and has src
|
||||||
|
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||||
|
if (!combinedImg || !combinedImg.src) {
|
||||||
|
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
|
||||||
|
// Try to use the first available image
|
||||||
|
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
|
||||||
|
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = i;
|
||||||
|
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final check - if still no valid image found
|
||||||
|
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||||
|
if (!finalImg || !finalImg.src) {
|
||||||
|
log.error("No valid images found in clipspace after attempting fixes");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Final clipspace structure:", {
|
||||||
|
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||||
|
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||||
|
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
|
||||||
|
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||||
|
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
|
||||||
|
* @param {any} node - The ComfyUI node to paste to
|
||||||
|
* @returns {boolean} - True if paste was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export function safeClipspacePaste(node: any): boolean {
|
||||||
|
log.debug("Attempting safe clipspace paste");
|
||||||
|
|
||||||
|
if (!validateAndFixClipspace()) {
|
||||||
|
log.debug("Clipspace validation failed, cannot paste");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ComfyApp.pasteFromClipspace(node);
|
||||||
|
log.debug("Successfully called pasteFromClipspace");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error calling pasteFromClipspace:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -386,3 +386,111 @@ export function canvasToMaskImage(canvas: HTMLCanvasElement): Promise<HTMLImageE
|
|||||||
img.src = canvas.toDataURL();
|
img.src = canvas.toDataURL();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scales an image to fit within specified bounds while maintaining aspect ratio
|
||||||
|
* @param image - Image to scale
|
||||||
|
* @param targetWidth - Target width to fit within
|
||||||
|
* @param targetHeight - Target height to fit within
|
||||||
|
* @returns Promise with scaled Image element
|
||||||
|
*/
|
||||||
|
export async function scaleImageToFit(image: HTMLImageElement, targetWidth: number, targetHeight: number): Promise<HTMLImageElement> {
|
||||||
|
const scale = Math.min(targetWidth / image.width, targetHeight / image.height);
|
||||||
|
const scaledWidth = Math.max(1, Math.round(image.width * scale));
|
||||||
|
const scaledHeight = Math.max(1, Math.round(image.height * scale));
|
||||||
|
|
||||||
|
const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create scaled image context");
|
||||||
|
|
||||||
|
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const scaledImg = new Image();
|
||||||
|
scaledImg.onload = () => resolve(scaledImg);
|
||||||
|
scaledImg.onerror = reject;
|
||||||
|
scaledImg.src = canvas.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified tensor to image data conversion
|
||||||
|
* Handles both RGB images and grayscale masks
|
||||||
|
* @param tensor - Input tensor data
|
||||||
|
* @param mode - 'rgb' for images or 'grayscale' for masks
|
||||||
|
* @returns ImageData object
|
||||||
|
*/
|
||||||
|
export function tensorToImageData(tensor: any, mode: 'rgb' | 'grayscale' = 'rgb'): ImageData | null {
|
||||||
|
try {
|
||||||
|
const shape = tensor.shape;
|
||||||
|
const height = shape[1];
|
||||||
|
const width = shape[2];
|
||||||
|
const channels = shape[3] || 1; // Default to 1 for masks
|
||||||
|
|
||||||
|
log.debug("Converting tensor:", { shape, channels, mode });
|
||||||
|
|
||||||
|
const imageData = new ImageData(width, height);
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
|
||||||
|
const flatData = tensor.data;
|
||||||
|
const pixelCount = width * height;
|
||||||
|
|
||||||
|
const min = tensor.min_val ?? 0;
|
||||||
|
const max = tensor.max_val ?? 1;
|
||||||
|
const denom = (max - min) || 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
|
const pixelIndex = i * 4;
|
||||||
|
const tensorIndex = i * channels;
|
||||||
|
|
||||||
|
let lum: number;
|
||||||
|
if (mode === 'grayscale' || channels === 1) {
|
||||||
|
lum = flatData[tensorIndex];
|
||||||
|
} else {
|
||||||
|
// Compute luminance for RGB
|
||||||
|
const r = flatData[tensorIndex + 0] ?? 0;
|
||||||
|
const g = flatData[tensorIndex + 1] ?? 0;
|
||||||
|
const b = flatData[tensorIndex + 2] ?? 0;
|
||||||
|
lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
let norm = (lum - min) / denom;
|
||||||
|
if (!isFinite(norm)) norm = 0;
|
||||||
|
norm = Math.max(0, Math.min(1, norm));
|
||||||
|
const value = Math.round(norm * 255);
|
||||||
|
|
||||||
|
if (mode === 'grayscale') {
|
||||||
|
// For masks: RGB = value, A = 255 (MaskTool reads luminance)
|
||||||
|
data[pixelIndex] = value;
|
||||||
|
data[pixelIndex + 1] = value;
|
||||||
|
data[pixelIndex + 2] = value;
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
} else {
|
||||||
|
// For images: RGB from channels, A = 255
|
||||||
|
for (let c = 0; c < Math.min(3, channels); c++) {
|
||||||
|
const channelValue = flatData[tensorIndex + c];
|
||||||
|
const channelNorm = (channelValue - min) / denom;
|
||||||
|
data[pixelIndex + c] = Math.round(channelNorm * 255);
|
||||||
|
}
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imageData.data.set(data);
|
||||||
|
return imageData;
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error converting tensor:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an HTMLImageElement from ImageData
|
||||||
|
* @param imageData - Input ImageData
|
||||||
|
* @returns Promise with HTMLImageElement
|
||||||
|
*/
|
||||||
|
export async function createImageFromImageData(imageData: ImageData): Promise<HTMLImageElement> {
|
||||||
|
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create canvas context");
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user