diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..fcb602f
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,29 @@
+"""
+BoxBox - Pacchetto nodi completo per selezione, crop, resize e reinserimento regioni immagine
+"""
+
+import os
+from .region_selector_node import RegionSelectorNode, NODE_CLASS_MAPPINGS as RSN_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as RSN_DISPLAY
+from .region_crop_node import RegionCropNode, NODE_CLASS_MAPPINGS as RCN_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as RCN_DISPLAY
+from .box_resize_node import BoxResizeNode, NODE_CLASS_MAPPINGS as BRN_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as BRN_DISPLAY
+from .box_reinsert_node import BoxReinsertNode, NODE_CLASS_MAPPINGS as BRI_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as BRI_DISPLAY
+
+# Combina le mappature di tutti i nodi
+NODE_CLASS_MAPPINGS = {
+ **RSN_MAPPINGS,
+ **RCN_MAPPINGS,
+ **BRN_MAPPINGS,
+ **BRI_MAPPINGS,
+}
+
+NODE_DISPLAY_NAME_MAPPINGS = {
+ **RSN_DISPLAY,
+ **RCN_DISPLAY,
+ **BRN_DISPLAY,
+ **BRI_DISPLAY,
+}
+
+# Percorso file web (HTML/CSS/JS)
+WEB_DIRECTORY = os.path.join(os.path.dirname(__file__), "web")
+
+__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
diff --git a/box_reinsert_node.py b/box_reinsert_node.py
new file mode 100644
index 0000000..32ab745
--- /dev/null
+++ b/box_reinsert_node.py
@@ -0,0 +1,148 @@
+"""
+Box Reinsert Node - Nodo per rimettere l'immagine generata nel punto originale
+"""
+
+import json
+import torch
+import numpy as np
+from PIL import Image
+
+
+class BoxReinsertNode:
+ """
+ Nodo che rimette l'immagine generata nel punto originale.
+
+ Workflow:
+ 1. BoxSelector → estrae una regione (metadata con x1, x2, y1, y2)
+ 2. BoxCrop → ritaglia la regione
+ 3. BoxResize → ridimensiona per generazione (metadata con scale info)
+ 4. [Generazione AI] → produce immagine generata
+ 5. BoxReinsert → annulla il resize e rimette nel punto originale
+
+ Input:
+ - original_image: Immagine originale intera
+ - generated_image: Immagine generata (ridimensionata)
+ - box_metadata: Metadata dal BoxSelector (coordinate selezione)
+ - resize_metadata: Metadata dal BoxResize (scale info)
+
+ Output:
+ - image: Immagine originale con generated_image rimessa nel posto corretto
+ """
+
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "original_image": ("IMAGE",),
+ "generated_image": ("IMAGE",),
+ "box_metadata": ("STRING",),
+ "resize_metadata": ("STRING",),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ RETURN_NAMES = ("image",)
+ FUNCTION = "reinsert_image"
+ CATEGORY = "image/box"
+
+ def reinsert_image(self, original_image, generated_image, box_metadata, resize_metadata):
+ """
+ Rimette l'immagine generata nel punto originale.
+
+ Se resize_metadata è vuoto, bypassa il resize e rimette direttamente l'immagine generata.
+
+ Args:
+ original_image: Immagine originale (B, H, W, C)
+ generated_image: Immagine generata/elaborata (B, H, W, C)
+ box_metadata: JSON metadata dal BoxSelector con x1, x2, y1, y2
+ resize_metadata: JSON metadata dal BoxResize con scale_x, scale_y (opzionale)
+
+ Returns:
+ (final_image,): Immagine con generated_image rimessa nel punto corretto
+ """
+
+ try:
+ box_meta = json.loads(box_metadata)
+ except json.JSONDecodeError:
+ print("[BoxReinsertNode] Invalid box_metadata JSON")
+ return (original_image,)
+
+ # Prova a leggere resize_metadata
+ resize_meta = {}
+ use_resize = False
+ if resize_metadata and resize_metadata.strip() and resize_metadata != "{}":
+ try:
+ resize_meta = json.loads(resize_metadata)
+ use_resize = True
+ except json.JSONDecodeError:
+ print("[BoxReinsertNode] Invalid resize_metadata JSON, bypassing resize")
+ use_resize = False
+
+ # Estrai coordinate della selezione originale
+ x1 = box_meta.get("x1", 0)
+ x2 = box_meta.get("x2", 0)
+ y1 = box_meta.get("y1", 0)
+ y2 = box_meta.get("y2", 0)
+
+ # Normalizza coordinate (assicura che x1 < x2, y1 < y2)
+ box_x_start = min(x1, x2)
+ box_x_end = max(x1, x2)
+ box_y_start = min(y1, y2)
+ box_y_end = max(y1, y2)
+
+ crop_width = box_x_end - box_x_start
+ crop_height = box_y_end - box_y_start
+
+ # Converti immagini a PIL
+ original_np = (original_image[0].cpu().numpy() * 255).astype(np.uint8)
+ original_pil = Image.fromarray(original_np)
+
+ generated_np = (generated_image[0].cpu().numpy() * 255).astype(np.uint8)
+ generated_pil = Image.fromarray(generated_np)
+
+ print(f"[BoxReinsertNode] Generated image size: {generated_pil.size}")
+ print(f"[BoxReinsertNode] Target crop size: {crop_width}x{crop_height}")
+ print(f"[BoxReinsertNode] Use resize: {use_resize}")
+
+ # Step 1: Ridimensiona solo se resize_metadata è fornito
+ if use_resize:
+ # Annulla il resize fatto da BoxResize
+ print(f"[BoxReinsertNode] Resizing generated image back to crop dimensions...")
+ generated_resized = generated_pil.resize((crop_width, crop_height), Image.Resampling.LANCZOS)
+ print(f"[BoxReinsertNode] Generated image resized from {generated_pil.size} to {generated_resized.size}")
+ else:
+ # Bypassa il resize e usa direttamente l'immagine generata
+ generated_resized = generated_pil
+ print(f"[BoxReinsertNode] Bypassing resize, using generated image as-is")
+
+ # Step 2: Rimetti nel punto originale
+ # Crea una copia dell'immagine originale
+ final_image = original_pil.copy()
+
+ # Incolla l'immagine generata nel punto corretto
+ final_image.paste(generated_resized, (box_x_start, box_y_start))
+
+ # Converti back a tensor
+ final_np = np.array(final_image).astype(np.float32) / 255.0
+ final_tensor = torch.from_numpy(final_np).unsqueeze(0)
+
+ # Assicurati che il tensor abbia le giuste dimensioni
+ channels = original_image.shape[3]
+ if final_tensor.shape[-1] != channels:
+ if channels == 4 and final_tensor.shape[-1] == 3:
+ alpha = torch.ones((final_tensor.shape[0], final_tensor.shape[1], final_tensor.shape[2], 1))
+ final_tensor = torch.cat([final_tensor, alpha], dim=-1)
+ elif channels == 3 and final_tensor.shape[-1] == 4:
+ final_tensor = final_tensor[:, :, :, :3]
+
+ print(f"[BoxReinsertNode] Reinserted generated image at position ({box_x_start}, {box_y_start})")
+ print(f"[BoxReinsertNode] Final image size: {final_tensor.shape[2]}x{final_tensor.shape[1]}")
+
+ return (final_tensor,)
+
+
+NODE_CLASS_MAPPINGS = {"BoxReinsert": BoxReinsertNode}
+NODE_DISPLAY_NAME_MAPPINGS = {"BoxReinsert": "🎨 BoxReinsert"}
diff --git a/box_resize_node.py b/box_resize_node.py
new file mode 100644
index 0000000..57fc68f
--- /dev/null
+++ b/box_resize_node.py
@@ -0,0 +1,191 @@
+"""
+Box Resize Node - Nodo per ridimensionare immagini con preset aspect ratio o custom
+"""
+
+import json
+import torch
+import torch.nn.functional as F
+from typing import Dict, Any, Tuple
+
+
+class BoxResizeNode:
+ """
+ Nodo che ridimensiona immagini con supporto per preset aspect ratio comuni.
+
+ Supporta:
+ - Preset di aspect ratio (1:1, 3:4, 5:8, 9:16, ecc...)
+ - Modalità custom width/height
+ - Keep aspect ratio
+ - Interpolazione (bilinear, bicubic, nearest)
+
+ Outputs metadata con informazioni complete di trasformazione.
+ """
+
+ RESIZE_PRESETS = {
+ "Custom": None,
+ "1:1 Square 1024x1024": (1024, 1024),
+ "3:4 Portrait 896x1152": (896, 1152),
+ "5:8 Portrait 832x1216": (832, 1216),
+ "9:16 Portrait 768x1344": (768, 1344),
+ "9:21 Portrait 640x1536": (640, 1536),
+ "4:3 Landscape 1152x896": (1152, 896),
+ "3:2 Landscape 1216x832": (1216, 832),
+ "16:9 Landscape 1344x768": (1344, 768),
+ "21:9 Landscape 1536x640": (1536, 640),
+ }
+
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls) -> Dict[str, Any]:
+ """
+ Definisce gli input del nodo.
+
+ Returns:
+ Dict con tipologie e configurazioni degli input
+ """
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "size": (list(cls.RESIZE_PRESETS.keys()), {
+ "default": "Custom"
+ }),
+ "keep_aspect_ratio": ("BOOLEAN", {
+ "default": True
+ }),
+ "interpolation_mode": (["bilinear", "bicubic", "nearest"], {
+ "default": "bilinear"
+ }),
+ },
+ "optional": {
+ "width": ("INT", {
+ "default": 1024,
+ "min": 64,
+ "max": 8192,
+ "step": 8
+ }),
+ "height": ("INT", {
+ "default": 1024,
+ "min": 64,
+ "max": 8192,
+ "step": 8
+ }),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE", "STRING")
+ RETURN_NAMES = ("image", "resize_metadata")
+ FUNCTION = "resize"
+ CATEGORY = "image/box"
+
+ def resize(
+ self,
+ image: torch.Tensor,
+ size: str,
+ keep_aspect_ratio: bool,
+ interpolation_mode: str = "bilinear",
+ width: int = 1024,
+ height: int = 1024
+ ) -> Tuple[torch.Tensor, str]:
+ """
+ Ridimensiona l'immagine con support per preset.
+
+ Args:
+ image: Tensor immagine in formato (batch, height, width, channels)
+ size: Preset selezionato o "Custom"
+ keep_aspect_ratio: Se True, mantiene le proporzioni
+ interpolation_mode: Modalità di interpolazione ("bilinear", "bicubic", "nearest")
+ width: Larghezza custom (se size = "Custom")
+ height: Altezza custom (se size = "Custom")
+
+ Returns:
+ Tuple contenente:
+ - resized_image: Tensor dell'immagine ridimensionata
+ - resize_metadata: Stringa JSON con informazioni di ridimensionamento
+ """
+
+ # Estrai dimensioni dal preset o usa custom
+ if size == "Custom":
+ target_width, target_height = width, height
+ else:
+ target_width, target_height = self.RESIZE_PRESETS[size]
+
+ # Valida l'input
+ if len(image.shape) != 4:
+ raise ValueError(f"Formato immagine non valido: atteso (batch, height, width, channels), ricevuto {image.shape}")
+
+ # Estrai le dimensioni originali
+ original_height = image.shape[1]
+ original_width = image.shape[2]
+ channels = image.shape[3]
+
+ # Calcola le dimensioni finali
+ if keep_aspect_ratio:
+ # Mantieni le proporzioni usando target_width come riferimento
+ aspect_ratio = original_height / original_width
+ final_width = target_width
+ final_height = int(target_width * aspect_ratio)
+ else:
+ final_width = target_width
+ final_height = target_height
+
+ # Se le dimensioni sono già corrette, ritorna l'immagine originale
+ if final_width == original_width and final_height == original_height:
+ resize_metadata = {
+ "original_width": int(original_width),
+ "original_height": int(original_height),
+ "resized_width": int(final_width),
+ "resized_height": int(final_height),
+ "scale_x": 1.0,
+ "scale_y": 1.0,
+ "size_preset": size,
+ "keep_aspect_ratio": keep_aspect_ratio,
+ "interpolation_mode": interpolation_mode
+ }
+ return (image, json.dumps(resize_metadata))
+
+ # Converte l'immagine da (batch, height, width, channels) a (batch, channels, height, width)
+ # formato richiesto da torch.nn.functional.interpolate
+ image_permuted = image.permute(0, 3, 1, 2)
+
+ # Esegui il ridimensionamento
+ align_corners = False if interpolation_mode != "nearest" else None
+
+ resized = F.interpolate(
+ image_permuted,
+ size=(final_height, final_width),
+ mode=interpolation_mode,
+ align_corners=align_corners
+ )
+
+ # Riconverti al formato originale: (batch, height, width, channels)
+ resized = resized.permute(0, 2, 3, 1)
+
+ # Calcola i fattori di scala
+ scale_x = final_width / original_width
+ scale_y = final_height / original_height
+
+ # Crea metadati con informazioni complete di ridimensionamento
+ resize_metadata = {
+ "original_width": int(original_width),
+ "original_height": int(original_height),
+ "resized_width": int(final_width),
+ "resized_height": int(final_height),
+ "scale_x": float(scale_x),
+ "scale_y": float(scale_y),
+ "size_preset": size,
+ "keep_aspect_ratio": keep_aspect_ratio,
+ "interpolation_mode": interpolation_mode
+ }
+
+ resize_metadata_str = json.dumps(resize_metadata)
+
+ print(f"[BoxResizeNode] Resized {original_width}x{original_height} → {final_width}x{final_height} (preset: {size}, keep_aspect: {keep_aspect_ratio})")
+ print(f"[BoxResizeNode] Metadata: {resize_metadata_str}")
+
+ return (resized, resize_metadata_str)
+
+
+NODE_CLASS_MAPPINGS = {"BoxResize": BoxResizeNode}
+NODE_DISPLAY_NAME_MAPPINGS = {"BoxResize": "📦 BoxResize"}
diff --git a/region_crop_node.py b/region_crop_node.py
new file mode 100644
index 0000000..906c1ee
--- /dev/null
+++ b/region_crop_node.py
@@ -0,0 +1,113 @@
+"""
+Region Crop Node - Nodo che taglia l'immagine secondo le coordinate selezionate
+"""
+
+import json
+import torch
+
+
+class RegionCropNode:
+ """
+ Nodo che taglia un'immagine usando le coordinate fornite dal RegionSelectorNode.
+ """
+
+ def __init__(self):
+ """Inizializza il nodo"""
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ """
+ Definisce gli input del nodo.
+ """
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "region_metadata": ("STRING", {
+ "default": "{}",
+ "multiline": True,
+ }),
+ },
+ "optional": {
+ "fallback_mode": (["use_full_image", "return_zero", "error"], {
+ "default": "use_full_image",
+ }),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ RETURN_NAMES = ("cropped_image",)
+ FUNCTION = "crop_image"
+ CATEGORY = "image/region"
+
+ def crop_image(self, image, region_metadata, fallback_mode="use_full_image"):
+ """
+ Taglia l'immagine secondo le coordinate nel metadata.
+
+ Args:
+ image: Tensor immagine (B, H, W, C)
+ region_metadata: JSON string con coordinate della regione
+ fallback_mode: Cosa fare se non ci sono coordinate valide
+
+ Returns:
+ (cropped_image,): Immagine ritagliata
+ """
+
+ try:
+ metadata = json.loads(region_metadata)
+ except json.JSONDecodeError:
+ metadata = {}
+
+ # Estrai coordinate (x1, x2, y1, y2 sono i lati)
+ x1 = metadata.get("x1", None)
+ x2 = metadata.get("x2", None)
+ y1 = metadata.get("y1", None)
+ y2 = metadata.get("y2", None)
+
+ # Se non c'è selezione
+ if x1 is None or x2 is None or y1 is None or y2 is None:
+ if fallback_mode == "use_full_image":
+ return (image,)
+ elif fallback_mode == "return_zero":
+ return (torch.zeros_like(image),)
+ else:
+ raise ValueError("No region coordinates provided in metadata")
+
+ # Assicura che le coordinate siano integer
+ x1 = int(round(x1))
+ x2 = int(round(x2))
+ y1 = int(round(y1))
+ y2 = int(round(y2))
+
+ # Estrai dimensioni immagine
+ batch_size, img_height, img_width, channels = image.shape
+
+ # Assicura che x1 < x2 e y1 < y2
+ x_start = min(x1, x2)
+ x_end = max(x1, x2)
+ y_start = min(y1, y2)
+ y_end = max(y1, y2)
+
+ # Calcola coordinate finali con clipping ai bordi
+ x_start = max(0, min(x_start, img_width - 1))
+ x_end = max(x_start + 1, min(x_end, img_width))
+ y_start = max(0, min(y_start, img_height - 1))
+ y_end = max(y_start + 1, min(y_end, img_height))
+
+ # Verifica validità della region
+ if x_end <= x_start or y_end <= y_start:
+ if fallback_mode == "use_full_image":
+ return (image,)
+ elif fallback_mode == "return_zero":
+ return (torch.zeros_like(image),)
+ else:
+ raise ValueError("Invalid region coordinates: no overlap with image")
+
+ # Taglia l'immagine
+ cropped = image[:, y_start:y_end, x_start:x_end, :]
+
+ return (cropped,)
+
+
+NODE_CLASS_MAPPINGS = {"BoxCrop": RegionCropNode}
+NODE_DISPLAY_NAME_MAPPINGS = {"BoxCrop": "✂️ BoxCrop"}
diff --git a/region_selector_node.py b/region_selector_node.py
new file mode 100644
index 0000000..c0e7073
--- /dev/null
+++ b/region_selector_node.py
@@ -0,0 +1,76 @@
+"""
+Region Selector Node - Nodo per selezionare regioni di immagini con interfaccia popup
+"""
+
+import json
+
+
+class RegionSelectorNode:
+ """
+ Nodo che permette di selezionare una regione rettangolare su un'immagine
+ tramite un'interfaccia interattiva in popup.
+
+ Outputs:
+ - IMAGE: L'immagine passata
+ - STRING: Metadata JSON con coordinate della regione selezionata
+ """
+
+ def __init__(self):
+ # Metadata di default con coordinate x1, x2, y1, y2 (lati, non posizione+dimensioni)
+ self.last_metadata = json.dumps({
+ "x1": 0,
+ "y1": 0,
+ "x2": 0,
+ "y2": 0,
+ "zoom": 1,
+ "borderWidth": 0,
+ "borderPosition": "inside",
+ "selected": False
+ })
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ },
+ "optional": {
+ "region_metadata": ("STRING", {
+ "default": "{}",
+ "multiline": True,
+ }),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE", "STRING")
+ RETURN_NAMES = ("image", "region_metadata")
+ FUNCTION = "process_region_selection"
+ CATEGORY = "image/region"
+ OUTPUT_NODE = True
+
+ def process_region_selection(self, image, region_metadata="{}"):
+ """
+ Processa l'immagine e ritorna l'immagine + metadata della regione selezionata.
+
+ Args:
+ image: Tensor immagine (B, H, W, C)
+ region_metadata: JSON string con coordinate della regione (x1, x2, y1, y2)
+
+ Returns:
+ (image, region_metadata_json): Immagine originale e metadata JSON
+ """
+
+ # Usa il metadata fornito, o l'ultimo salvato se vuoto
+ if region_metadata and region_metadata.strip() and region_metadata != "{}":
+ metadata_to_return = region_metadata
+ self.last_metadata = region_metadata
+ else:
+ # Se nessun metadata è stato fornito, usa l'ultimo salvato
+ metadata_to_return = self.last_metadata
+
+ print(f"[RegionSelectorNode] Processing selection, metadata: {metadata_to_return}")
+ return (image, metadata_to_return)
+
+
+NODE_CLASS_MAPPINGS = {"BoxSelector": RegionSelectorNode}
+NODE_DISPLAY_NAME_MAPPINGS = {"BoxSelector": "📦 BoxSelector"}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..b82f7c8
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+torch>=2.0.0
+numpy>=1.20.0
+Pillow>=8.0.0
diff --git a/web/canvas_selector.js b/web/canvas_selector.js
new file mode 100644
index 0000000..f148ab0
--- /dev/null
+++ b/web/canvas_selector.js
@@ -0,0 +1,516 @@
+/**
+ * Canvas Selector - Funzionalità complete di selezione rettangoli
+ * Basato sul codice di canva_html
+ */
+
+function initializeCanvasSelector(container, imageUrl) {
+ console.log("[CanvasSelector] Initializing with image:", imageUrl);
+
+ // Riferimenti agli elementi DOM (cercati dentro il container)
+ const canvasContainer = container.querySelector('#canvas-container');
+ const backgroundImage = container.querySelector('#background-image');
+ const baseCoordinates = container.querySelector('#base-coordinates');
+ const dimensionsInfo = container.querySelector('#dimensions-info');
+ const currentDimensions = container.querySelector('#current-dimensions');
+ const zoomInBtn = container.querySelector('#zoom-in-btn');
+ const zoomOutBtn = container.querySelector('#zoom-out-btn');
+ const zoomValue = container.querySelector('#zoom-value');
+ const borderSlider = container.querySelector('#border-slider');
+ const borderValue = container.querySelector('#border-value');
+ const resetBtn = container.querySelector('#reset-btn');
+ const borderPositionRadios = container.querySelectorAll('input[name="border-position"]');
+ const imageUpload = container.querySelector('#image-upload');
+ const uploadBtn = container.querySelector('#upload-btn');
+ const imageName = container.querySelector('#image-name');
+
+ // Variabili di stato
+ let isDrawing = false;
+ let isResizing = false;
+ let resizingEdge = null;
+ let startX = 0;
+ let startY = 0;
+ let rectStartX = 0;
+ let rectStartY = 0;
+ let rectStartWidth = 0;
+ let rectStartHeight = 0;
+ let currentRectangle = null;
+ let currentZoom = 1;
+ let currentBorderWidth = 0;
+ let borderPosition = 'inside';
+ let rectangleExists = false;
+
+ // Dimensioni base
+ let baseWidth = 0;
+ let baseHeight = 0;
+ let baseX = 0;
+ let baseY = 0;
+
+ const defaultBorderWidth = 3;
+ const zoomStep = 0.25;
+ const minZoom = 0.25;
+ const maxZoom = 4;
+
+ // Carica l'immagine iniziale
+ if (imageUrl) {
+ backgroundImage.src = imageUrl;
+ const filename = imageUrl.split('/').pop();
+ imageName.textContent = `Immagine: ${filename}`;
+ }
+
+ // ========== EVENT LISTENERS ==========
+
+ // Upload button
+ uploadBtn.addEventListener('click', () => imageUpload.click());
+
+ imageUpload.addEventListener('change', (e) => {
+ const file = e.target.files[0];
+ if (file && file.type.startsWith('image/')) {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ backgroundImage.src = event.target.result;
+ imageName.textContent = `Immagine: ${file.name}`;
+ if (currentRectangle && confirm('Cambiando immagine il rettangolo verrà cancellato. Continuare?')) {
+ resetRectangle();
+ }
+ };
+ reader.readAsDataURL(file);
+ }
+ });
+
+ // Zoom buttons
+ zoomInBtn.addEventListener('click', () => {
+ if (currentRectangle) {
+ currentZoom = Math.min(currentZoom + zoomStep, maxZoom);
+ updateZoom();
+ updateAllDimensions();
+ }
+ });
+
+ zoomOutBtn.addEventListener('click', () => {
+ if (currentRectangle) {
+ currentZoom = Math.max(currentZoom - zoomStep, minZoom);
+ updateZoom();
+ updateAllDimensions();
+ }
+ });
+
+ // Border slider
+ borderSlider.addEventListener('input', (e) => {
+ currentBorderWidth = parseInt(e.target.value);
+ borderValue.textContent = currentBorderWidth + 'px';
+
+ if (currentRectangle) {
+ if (currentRectangle.classList.contains('border-outside')) {
+ currentRectangle.style.outlineWidth = currentBorderWidth + 'px';
+ } else {
+ currentRectangle.style.borderWidth = currentBorderWidth + 'px';
+ }
+
+ if (currentBorderWidth > defaultBorderWidth) {
+ currentRectangle.classList.add('thick-border');
+ } else {
+ currentRectangle.classList.remove('thick-border');
+ }
+
+ updateAllDimensions();
+ }
+ });
+
+ // Border position radios
+ borderPositionRadios.forEach(radio => {
+ radio.addEventListener('change', (e) => {
+ borderPosition = e.target.value;
+
+ if (currentRectangle) {
+ if (borderPosition === 'outside') {
+ currentRectangle.classList.remove('border-inside');
+ currentRectangle.classList.add('border-outside');
+ currentRectangle.style.outlineWidth = currentBorderWidth + 'px';
+ currentRectangle.style.borderWidth = '0px';
+ } else {
+ currentRectangle.classList.remove('border-outside');
+ currentRectangle.classList.add('border-inside');
+ currentRectangle.style.borderWidth = currentBorderWidth + 'px';
+ currentRectangle.style.outlineWidth = '0px';
+ }
+
+ updateAllDimensions();
+ }
+ });
+ });
+
+ // Reset button
+ resetBtn.addEventListener('click', () => {
+ if (confirm('Vuoi cancellare il rettangolo e disegnarne uno nuovo?')) {
+ resetRectangle();
+ }
+ });
+
+ // Mouse down - inizio disegno
+ canvasContainer.addEventListener('mousedown', (e) => {
+ if (rectangleExists) return;
+ if (e.target.classList.contains('resize-handle')) return;
+
+ const rect = canvasContainer.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+
+ isDrawing = true;
+
+ currentRectangle = document.createElement('div');
+ currentRectangle.className = 'rectangle';
+
+ if (borderPosition === 'outside') {
+ currentRectangle.classList.add('border-outside');
+ currentRectangle.style.outlineWidth = currentBorderWidth + 'px';
+ currentRectangle.style.outlineStyle = 'solid';
+ currentRectangle.style.borderWidth = '0px';
+ } else {
+ currentRectangle.classList.add('border-inside');
+ currentRectangle.style.borderWidth = currentBorderWidth + 'px';
+ }
+
+ currentRectangle.style.left = startX + 'px';
+ currentRectangle.style.top = startY + 'px';
+ currentRectangle.style.width = '0px';
+ currentRectangle.style.height = '0px';
+
+ if (currentBorderWidth > defaultBorderWidth) {
+ currentRectangle.classList.add('thick-border');
+ }
+
+ canvasContainer.appendChild(currentRectangle);
+ });
+
+ // Mouse move
+ document.addEventListener('mousemove', (e) => {
+ if (isDrawing) {
+ const rect = canvasContainer.getBoundingClientRect();
+ const currentX = e.clientX - rect.left;
+ const currentY = e.clientY - rect.top;
+
+ const width = currentX - startX;
+ const height = currentY - startY;
+
+ if (width < 0) {
+ currentRectangle.style.left = currentX + 'px';
+ currentRectangle.style.width = Math.abs(width) + 'px';
+ } else {
+ currentRectangle.style.width = width + 'px';
+ }
+
+ if (height < 0) {
+ currentRectangle.style.top = currentY + 'px';
+ currentRectangle.style.height = Math.abs(height) + 'px';
+ } else {
+ currentRectangle.style.height = height + 'px';
+ }
+ }
+
+ if (isResizing && currentRectangle) {
+ const deltaX = e.clientX - startX;
+ const deltaY = e.clientY - startY;
+
+ switch(resizingEdge) {
+ case 'top':
+ const newTop = rectStartY + deltaY;
+ const newHeight = rectStartHeight - deltaY;
+ if (newHeight > 0) {
+ currentRectangle.style.top = newTop + 'px';
+ currentRectangle.style.height = newHeight + 'px';
+ }
+ break;
+
+ case 'bottom':
+ const bottomHeight = rectStartHeight + deltaY;
+ if (bottomHeight > 0) {
+ currentRectangle.style.height = bottomHeight + 'px';
+ }
+ break;
+
+ case 'left':
+ const newLeft = rectStartX + deltaX;
+ const newWidth = rectStartWidth - deltaX;
+ if (newWidth > 0) {
+ currentRectangle.style.left = newLeft + 'px';
+ currentRectangle.style.width = newWidth + 'px';
+ }
+ break;
+
+ case 'right':
+ const rightWidth = rectStartWidth + deltaX;
+ if (rightWidth > 0) {
+ currentRectangle.style.width = rightWidth + 'px';
+ }
+ break;
+
+ case 'top-left':
+ const tlNewTop = rectStartY + deltaY;
+ const tlNewLeft = rectStartX + deltaX;
+ const tlNewHeight = rectStartHeight - deltaY;
+ const tlNewWidth = rectStartWidth - deltaX;
+ if (tlNewHeight > 0 && tlNewWidth > 0) {
+ currentRectangle.style.top = tlNewTop + 'px';
+ currentRectangle.style.left = tlNewLeft + 'px';
+ currentRectangle.style.height = tlNewHeight + 'px';
+ currentRectangle.style.width = tlNewWidth + 'px';
+ }
+ break;
+
+ case 'top-right':
+ const trNewTop = rectStartY + deltaY;
+ const trNewHeight = rectStartHeight - deltaY;
+ const trNewWidth = rectStartWidth + deltaX;
+ if (trNewHeight > 0 && trNewWidth > 0) {
+ currentRectangle.style.top = trNewTop + 'px';
+ currentRectangle.style.height = trNewHeight + 'px';
+ currentRectangle.style.width = trNewWidth + 'px';
+ }
+ break;
+
+ case 'bottom-left':
+ const blNewLeft = rectStartX + deltaX;
+ const blNewHeight = rectStartHeight + deltaY;
+ const blNewWidth = rectStartWidth - deltaX;
+ if (blNewHeight > 0 && blNewWidth > 0) {
+ currentRectangle.style.left = blNewLeft + 'px';
+ currentRectangle.style.height = blNewHeight + 'px';
+ currentRectangle.style.width = blNewWidth + 'px';
+ }
+ break;
+
+ case 'bottom-right':
+ const brNewWidth = rectStartWidth + deltaX;
+ const brNewHeight = rectStartHeight + deltaY;
+ if (brNewHeight > 0 && brNewWidth > 0) {
+ currentRectangle.style.width = brNewWidth + 'px';
+ currentRectangle.style.height = brNewHeight + 'px';
+ }
+ break;
+ }
+
+ baseX = parseFloat(currentRectangle.style.left);
+ baseY = parseFloat(currentRectangle.style.top);
+ baseWidth = parseFloat(currentRectangle.style.width);
+ baseHeight = parseFloat(currentRectangle.style.height);
+
+ updateAllDimensions();
+ }
+ });
+
+ // Mouse up
+ document.addEventListener('mouseup', (e) => {
+ if (isDrawing) {
+ const rect = canvasContainer.getBoundingClientRect();
+ const endX = e.clientX - rect.left;
+ const endY = e.clientY - rect.top;
+
+ const x1 = Math.min(startX, endX);
+ const y1 = Math.min(startY, endY);
+ const x2 = Math.max(startX, endX);
+ const y2 = Math.max(startY, endY);
+
+ baseX = x1;
+ baseY = y1;
+ baseWidth = x2 - x1;
+ baseHeight = y2 - y1;
+
+ rectangleExists = true;
+ currentRectangle.classList.add('complete');
+
+ canvasContainer.classList.add('drawing-disabled');
+ canvasContainer.style.cursor = 'default';
+
+ resetBtn.disabled = false;
+ zoomInBtn.disabled = currentZoom >= maxZoom;
+ zoomOutBtn.disabled = currentZoom <= minZoom;
+
+ dimensionsInfo.style.display = 'block';
+
+ addResizeHandles();
+ updateAllDimensions();
+
+ isDrawing = false;
+ }
+
+ if (isResizing) {
+ isResizing = false;
+ resizingEdge = null;
+ canvasContainer.style.cursor = 'default';
+ }
+ });
+
+ // ========== FUNZIONI HELPER ==========
+
+ function resetRectangle() {
+ if (currentRectangle) {
+ currentRectangle.remove();
+ }
+ currentRectangle = null;
+ rectangleExists = false;
+ currentZoom = 1;
+ updateZoom();
+ canvasContainer.classList.remove('drawing-disabled');
+ canvasContainer.style.cursor = 'crosshair';
+ resetBtn.disabled = true;
+ zoomInBtn.disabled = true;
+ zoomOutBtn.disabled = true;
+ dimensionsInfo.style.display = 'none';
+ baseCoordinates.innerHTML = '
Clicca e trascina sull\'immagine per disegnare un rettangolo
';
+ }
+
+ function updateZoom() {
+ const zoomPercent = Math.round(currentZoom * 100);
+ zoomValue.textContent = zoomPercent + '%';
+ if (currentRectangle) {
+ currentRectangle.style.transform = `scale(${currentZoom})`;
+ }
+
+ zoomInBtn.disabled = currentZoom >= maxZoom;
+ zoomOutBtn.disabled = currentZoom <= minZoom;
+ }
+
+ function addResizeHandles() {
+ const handles = ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
+ handles.forEach(position => {
+ const handle = document.createElement('div');
+ handle.className = `resize-handle ${position}`;
+ handle.dataset.edge = position;
+ currentRectangle.appendChild(handle);
+
+ handle.addEventListener('mousedown', (e) => {
+ e.stopPropagation();
+ isResizing = true;
+ resizingEdge = position;
+ startX = e.clientX;
+ startY = e.clientY;
+
+ rectStartX = parseFloat(currentRectangle.style.left);
+ rectStartY = parseFloat(currentRectangle.style.top);
+ rectStartWidth = parseFloat(currentRectangle.style.width);
+ rectStartHeight = parseFloat(currentRectangle.style.height);
+
+ canvasContainer.style.cursor = getComputedStyle(handle).cursor;
+ });
+ });
+ }
+
+ function updateAllDimensions() {
+ if (!currentRectangle) return;
+
+ const baseX1 = baseX;
+ const baseY1 = baseY;
+ const baseX2 = baseX + baseWidth;
+ const baseY2 = baseY + baseHeight;
+
+ const centerX = baseX + (baseWidth / 2);
+ const centerY = baseY + (baseHeight / 2);
+
+ const zoomedWidth = baseWidth * currentZoom;
+ const zoomedHeight = baseHeight * currentZoom;
+
+ const zoomedX1 = centerX - (zoomedWidth / 2);
+ const zoomedY1 = centerY - (zoomedHeight / 2);
+ const zoomedX2 = centerX + (zoomedWidth / 2);
+ const zoomedY2 = centerY + (zoomedHeight / 2);
+
+ let effectiveX1, effectiveY1, effectiveX2, effectiveY2;
+ let effectiveWidth, effectiveHeight;
+
+ if (borderPosition === 'inside') {
+ effectiveX1 = zoomedX1;
+ effectiveY1 = zoomedY1;
+ effectiveX2 = zoomedX2;
+ effectiveY2 = zoomedY2;
+ effectiveWidth = zoomedWidth;
+ effectiveHeight = zoomedHeight;
+ } else {
+ effectiveX1 = zoomedX1 - currentBorderWidth;
+ effectiveY1 = zoomedY1 - currentBorderWidth;
+ effectiveX2 = zoomedX2 + currentBorderWidth;
+ effectiveY2 = zoomedY2 + currentBorderWidth;
+ effectiveWidth = zoomedWidth + (currentBorderWidth * 2);
+ effectiveHeight = zoomedHeight + (currentBorderWidth * 2);
+ }
+
+ baseCoordinates.innerHTML = `
+ 📍 Coordinate Base:
+ x1 = ${Math.round(baseX1)}px, y1 = ${Math.round(baseY1)}px
+ x2 = ${Math.round(baseX2)}px, y2 = ${Math.round(baseY2)}px
+
+ 📏 Dimensioni Base:
+ Larghezza: ${Math.round(baseWidth)}px
+ Altezza: ${Math.round(baseHeight)}px
+ `;
+
+ currentDimensions.innerHTML = `
+ 🔍 Con Zoom (${Math.round(currentZoom * 100)}%):
+ Larghezza: ${Math.round(zoomedWidth)}px
+ Altezza: ${Math.round(zoomedHeight)}px
+
+ 🎨 Bordo ${borderPosition === 'inside' ? 'Interno' : 'Esterno'} (${currentBorderWidth}px):
+ Larghezza Totale: ${Math.round(effectiveWidth)}px
+ Altezza Totale: ${Math.round(effectiveHeight)}px
+ `;
+ }
+
+ // Esporta la funzione per ottenere le coordinate
+ return {
+ getCoordinates: () => {
+ // Calcola le coordinate dei lati (x1, x2, y1, y2) con zoom
+ const baseX1 = baseX;
+ const baseY1 = baseY;
+ const baseX2 = baseX + baseWidth;
+ const baseY2 = baseY + baseHeight;
+
+ // Applica lo zoom mantenendo il centro
+ const centerX = baseX + (baseWidth / 2);
+ const centerY = baseY + (baseHeight / 2);
+ const zoomedWidth = baseWidth * currentZoom;
+ const zoomedHeight = baseHeight * currentZoom;
+ const zoomedX1 = centerX - (zoomedWidth / 2);
+ const zoomedY1 = centerY - (zoomedHeight / 2);
+ const zoomedX2 = centerX + (zoomedWidth / 2);
+ const zoomedY2 = centerY + (zoomedHeight / 2);
+
+ // Applica il bordo
+ let effectiveX1 = zoomedX1;
+ let effectiveY1 = zoomedY1;
+ let effectiveX2 = zoomedX2;
+ let effectiveY2 = zoomedY2;
+
+ if (borderPosition === 'outside') {
+ // Se il bordo è esterno, espandi le coordinate
+ effectiveX1 = Math.max(0, zoomedX1 - currentBorderWidth);
+ effectiveY1 = Math.max(0, zoomedY1 - currentBorderWidth);
+ effectiveX2 = zoomedX2 + currentBorderWidth;
+ effectiveY2 = zoomedY2 + currentBorderWidth;
+ } else {
+ // Se il bordo è interno, restringe le coordinate
+ effectiveX1 = zoomedX1 + currentBorderWidth;
+ effectiveY1 = zoomedY1 + currentBorderWidth;
+ effectiveX2 = Math.max(effectiveX1 + 1, zoomedX2 - currentBorderWidth);
+ effectiveY2 = Math.max(effectiveY1 + 1, zoomedY2 - currentBorderWidth);
+ }
+
+ return {
+ x1: Math.round(effectiveX1),
+ y1: Math.round(effectiveY1),
+ x2: Math.round(effectiveX2),
+ y2: Math.round(effectiveY2),
+ zoom: currentZoom,
+ borderWidth: currentBorderWidth,
+ borderPosition: borderPosition,
+ };
+ },
+ getState: () => ({
+ exists: rectangleExists,
+ baseX, baseY, baseWidth, baseHeight,
+ zoom: currentZoom,
+ borderWidth: currentBorderWidth,
+ borderPosition
+ })
+ };
+}
+
+window.CanvasSelector = { initializeCanvasSelector };
diff --git a/web/region_selector.css b/web/region_selector.css
new file mode 100644
index 0000000..0e9faa4
--- /dev/null
+++ b/web/region_selector.css
@@ -0,0 +1,503 @@
+/* Region Selector Stylesheet */
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ --primary-color: #2563eb;
+ --secondary-color: #64748b;
+ --success-color: #16a34a;
+ --danger-color: #dc2626;
+ --warning-color: #ea580c;
+ --bg-color: #f8fafc;
+ --panel-bg: #ffffff;
+ --border-color: #e2e8f0;
+ --text-color: #1e293b;
+ --text-light: #64748b;
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ line-height: 1.6;
+}
+
+.selector-container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ background-color: var(--panel-bg);
+}
+
+/* Header */
+.selector-header {
+ position: relative;
+ padding: 20px 25px;
+ background: linear-gradient(135deg, var(--primary-color) 0%, #1e40af 100%);
+ color: white;
+ box-shadow: var(--shadow);
+ z-index: 10;
+}
+
+.selector-header h1 {
+ font-size: 24px;
+ margin-bottom: 5px;
+ font-weight: 700;
+}
+
+.selector-header p {
+ font-size: 13px;
+ opacity: 0.9;
+ margin-bottom: 0;
+}
+
+.close-button {
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ background: rgba(255, 255, 255, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: white;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ font-size: 18px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.close-button:hover {
+ background: rgba(255, 255, 255, 0.3);
+ border-color: rgba(255, 255, 255, 0.5);
+}
+
+/* Content Layout */
+.selector-content {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* Control Panel */
+.control-panel {
+ width: 320px;
+ background-color: var(--panel-bg);
+ border-right: 1px solid var(--border-color);
+ overflow-y: auto;
+ padding: 20px;
+}
+
+.control-panel h3 {
+ font-size: 16px;
+ margin-bottom: 15px;
+ color: var(--text-color);
+ font-weight: 600;
+}
+
+.control-panel h4 {
+ font-size: 13px;
+ margin-bottom: 10px;
+ color: var(--text-color);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.control-group {
+ margin-bottom: 20px;
+}
+
+.control-group label {
+ display: block;
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 8px;
+ color: var(--text-color);
+}
+
+.control-group small {
+ display: block;
+ font-size: 11px;
+ color: var(--text-light);
+ margin-top: 5px;
+ font-style: italic;
+}
+
+/* Input Range */
+input[type="range"] {
+ width: 100%;
+ height: 6px;
+ border-radius: 3px;
+ background: var(--border-color);
+ outline: none;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--primary-color);
+ cursor: pointer;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ transition: all 0.2s ease;
+}
+
+input[type="range"]::-webkit-slider-thumb:hover {
+ transform: scale(1.2);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
+}
+
+input[type="range"]::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--primary-color);
+ cursor: pointer;
+ border: none;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ transition: all 0.2s ease;
+}
+
+/* Buttons */
+.btn {
+ padding: 8px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.btn:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow);
+}
+
+.btn:active:not(:disabled) {
+ transform: translateY(0);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background-color: var(--primary-color);
+ color: white;
+ border-color: var(--primary-color);
+ width: 100%;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background-color: #1d4ed8;
+}
+
+.btn-secondary {
+ background-color: var(--secondary-color);
+ color: white;
+ border-color: var(--secondary-color);
+ width: 100%;
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background-color: #475569;
+}
+
+.btn-success {
+ background-color: var(--success-color);
+ color: white;
+ border-color: var(--success-color);
+ width: 100%;
+ font-weight: 600;
+}
+
+.btn-success:hover:not(:disabled) {
+ background-color: #15803d;
+}
+
+.btn-danger {
+ background-color: var(--danger-color);
+ color: white;
+ border-color: var(--danger-color);
+ width: 100%;
+ font-weight: 600;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background-color: #b91c1c;
+}
+
+.btn-small {
+ width: 100%;
+ padding: 6px 10px;
+ font-size: 12px;
+}
+
+.zoom-buttons {
+ display: flex;
+ gap: 8px;
+}
+
+.zoom-buttons .btn {
+ flex: 1;
+}
+
+/* Radio Group */
+.radio-group {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.radio-group label {
+ display: flex;
+ align-items: center;
+ font-weight: 400;
+ margin-bottom: 0;
+ cursor: pointer;
+}
+
+.radio-group input[type="radio"] {
+ margin-right: 8px;
+ cursor: pointer;
+ width: 16px;
+ height: 16px;
+ accent-color: var(--primary-color);
+}
+
+/* Zoom Display */
+.zoom-display {
+ background-color: #f1f5f9;
+ padding: 10px;
+ border-radius: 4px;
+ text-align: center;
+ margin: 10px 0;
+ font-weight: 600;
+ color: var(--primary-color);
+}
+
+/* Info Boxes */
+.info-box {
+ background-color: #f9fafb;
+ border: 1px solid var(--border-color);
+ padding: 12px;
+ border-radius: 6px;
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 12px;
+ color: var(--text-color);
+ line-height: 1.5;
+ overflow-x: auto;
+}
+
+.info-box p {
+ margin: 0;
+}
+
+.info-box-secondary {
+ background-color: #eff6ff;
+ border-color: #bfdbfe;
+}
+
+/* Action Buttons */
+.action-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.action-buttons .btn {
+ padding: 10px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+/* HR */
+hr {
+ border: none;
+ border-top: 1px solid var(--border-color);
+ margin: 15px 0;
+}
+
+/* Canvas Area */
+.canvas-area {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(45deg, #f0f4f8 25%, transparent 25%, transparent 75%, #f0f4f8 75%, #f0f4f8),
+ linear-gradient(45deg, #f0f4f8 25%, transparent 25%, transparent 75%, #f0f4f8 75%, #f0f4f8);
+ background-size: 40px 40px;
+ background-position: 0 0, 20px 20px;
+ background-color: #fafbfc;
+ overflow: hidden;
+ position: relative;
+}
+
+#canvas-container {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background-color: white;
+ border-radius: 8px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
+ overflow: hidden;
+ max-width: 90%;
+ max-height: 90%;
+}
+
+#background-image {
+ display: block;
+ max-width: 100%;
+ max-height: 100%;
+ user-select: none;
+ -webkit-user-select: none;
+ -webkit-user-drag: none;
+}
+
+/* Rectangle */
+.rectangle {
+ position: absolute;
+ background-color: transparent;
+ cursor: move;
+ transform-origin: center center;
+ transition: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.rectangle.border-inside {
+ border: 3px solid #2563eb;
+ box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.3);
+}
+
+.rectangle.border-outside {
+ outline: 3px solid #2563eb;
+ outline-offset: -3px;
+}
+
+/* Resize Handles */
+.resize-handle {
+ position: absolute;
+ width: 12px;
+ height: 12px;
+ background-color: #2563eb;
+ border: 2px solid white;
+ border-radius: 50%;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.rectangle:hover .resize-handle,
+.rectangle.resizing .resize-handle {
+ opacity: 1;
+}
+
+.rectangle:hover {
+ box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.2);
+}
+
+/* Handle positions */
+.resize-handle.top-left {
+ top: -6px;
+ left: -6px;
+ cursor: nwse-resize;
+}
+
+.resize-handle.top-right {
+ top: -6px;
+ right: -6px;
+ cursor: nesw-resize;
+}
+
+.resize-handle.bottom-left {
+ bottom: -6px;
+ left: -6px;
+ cursor: nesw-resize;
+}
+
+.resize-handle.bottom-right {
+ bottom: -6px;
+ right: -6px;
+ cursor: nwse-resize;
+}
+
+/* Scrollbar Styling */
+.control-panel::-webkit-scrollbar {
+ width: 8px;
+}
+
+.control-panel::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.control-panel::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 4px;
+}
+
+.control-panel::-webkit-scrollbar-thumb:hover {
+ background: var(--secondary-color);
+}
+
+/* Responsive Design */
+@media (max-width: 1024px) {
+ .control-panel {
+ width: 280px;
+ }
+}
+
+@media (max-width: 768px) {
+ .selector-content {
+ flex-direction: column;
+ }
+
+ .control-panel {
+ width: 100%;
+ border-right: none;
+ border-bottom: 1px solid var(--border-color);
+ max-height: 40vh;
+ }
+
+ .canvas-area {
+ flex: 1;
+ min-height: 60vh;
+ }
+
+ .control-panel h3 {
+ margin-top: 10px;
+ }
+}
+
+/* Accessibility */
+.btn:focus,
+input:focus,
+textarea:focus {
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+}
+
+/* Utility */
+.hidden {
+ display: none !important;
+}
diff --git a/web/region_selector.html b/web/region_selector.html
new file mode 100644
index 0000000..18a4e05
--- /dev/null
+++ b/web/region_selector.html
@@ -0,0 +1,626 @@
+
+
+
+
+
+ Region Selector - ComfyUI
+
+
+
+
+
+
+
+
+
+
+
+
Controlli
+
+
+
+
+
+
+ Immagine corrente: picsum.photos
+
+
+
+
+
+
+
+
+
+
+
+
+ 100%
+
+
Zoom del rettangolo selezionato
+
+
+
+
+
+
+ Larghezza linea di selezione
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Informazioni Selezione
+
+
Clicca e trascina sull'immagine per selezionare una regione
+
+
+
+
Dimensioni Con Zoom
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
diff --git a/web/region_selector.js b/web/region_selector.js
new file mode 100644
index 0000000..0fa73b1
--- /dev/null
+++ b/web/region_selector.js
@@ -0,0 +1,584 @@
+/**
+ * Region Selector JavaScript - Logica interazione canvas editor
+ */
+
+// ============================================================================
+// DOM ELEMENTS
+// ============================================================================
+
+const container = document.getElementById('canvas-container');
+const backgroundImage = document.getElementById('background-image');
+const baseCoordinates = document.getElementById('base-coordinates');
+const dimensionsInfo = document.getElementById('dimensions-info');
+const currentDimensions = document.getElementById('current-dimensions');
+const zoomInBtn = document.getElementById('zoom-in-btn');
+const zoomOutBtn = document.getElementById('zoom-out-btn');
+const zoomValue = document.getElementById('zoom-value');
+const borderSlider = document.getElementById('border-slider');
+const borderValue = document.getElementById('border-value');
+const resetBtn = document.getElementById('reset-btn');
+const borderPositionRadios = document.querySelectorAll('input[name="border-position"]');
+const imageUpload = document.getElementById('image-upload');
+const uploadBtn = document.getElementById('upload-btn');
+const imageName = document.getElementById('image-name');
+const confirmBtn = document.getElementById('confirm-btn');
+const cancelBtn = document.getElementById('cancel-btn');
+const closeBtn = document.getElementById('close-btn');
+
+// ============================================================================
+// STATE VARIABLES
+// ============================================================================
+
+let isDrawing = false;
+let isResizing = false;
+let resizingEdge = null;
+let startX = 0;
+let startY = 0;
+let rectStartX = 0;
+let rectStartY = 0;
+let rectStartWidth = 0;
+let rectStartHeight = 0;
+let currentRectangle = null;
+let currentZoom = 1;
+let currentBorderWidth = 3;
+let borderPosition = 'inside';
+let rectangleExists = false;
+
+// Coordinate base (senza zoom e bordi)
+let baseWidth = 0;
+let baseHeight = 0;
+let baseX = 0;
+let baseY = 0;
+
+const defaultBorderWidth = 3;
+const zoomStep = 0.25;
+const minZoom = 0.25;
+const maxZoom = 4;
+
+// Callback per comunicazione con il nodo
+let onConfirmCallback = null;
+let onCancelCallback = null;
+
+// ============================================================================
+// INITIALIZATION
+// ============================================================================
+
+document.addEventListener('DOMContentLoaded', function() {
+ initializeEventListeners();
+});
+
+function initializeEventListeners() {
+ // Upload button
+ uploadBtn.addEventListener('click', handleUploadClick);
+ imageUpload.addEventListener('change', handleImageUpload);
+
+ // Zoom buttons
+ zoomInBtn.addEventListener('click', handleZoomIn);
+ zoomOutBtn.addEventListener('click', handleZoomOut);
+
+ // Border controls
+ borderSlider.addEventListener('input', handleBorderChange);
+ borderPositionRadios.forEach(radio => {
+ radio.addEventListener('change', handleBorderPositionChange);
+ });
+
+ // Reset button
+ resetBtn.addEventListener('click', handleReset);
+
+ // Canvas drawing
+ container.addEventListener('mousedown', handleCanvasMouseDown);
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+
+ // Action buttons
+ confirmBtn.addEventListener('click', handleConfirm);
+ cancelBtn.addEventListener('click', handleCancel);
+ closeBtn.addEventListener('click', handleCancel);
+
+ // Prevent image drag
+ backgroundImage.addEventListener('dragstart', (e) => e.preventDefault());
+}
+
+// ============================================================================
+// FILE UPLOAD
+// ============================================================================
+
+function handleUploadClick() {
+ imageUpload.click();
+}
+
+function handleImageUpload(e) {
+ const file = e.target.files[0];
+ if (file && file.type.startsWith('image/')) {
+ const reader = new FileReader();
+
+ reader.onload = function(event) {
+ backgroundImage.src = event.target.result;
+ imageName.textContent = `Immagine corrente: ${file.name}`;
+
+ if (currentRectangle) {
+ if (confirm('Cambiando immagine il rettangolo verrà cancellato. Continuare?')) {
+ resetRectangle();
+ } else {
+ imageUpload.value = '';
+ return;
+ }
+ }
+ };
+
+ reader.readAsDataURL(file);
+ }
+}
+
+// ============================================================================
+// RECTANGLE DRAWING
+// ============================================================================
+
+function handleCanvasMouseDown(e) {
+ if (e.button !== 0) return; // Solo left click
+ if (!container || !backgroundImage) return;
+
+ const rect = container.getBoundingClientRect();
+ const imgRect = backgroundImage.getBoundingClientRect();
+ const containerRect = container.getBoundingClientRect();
+
+ // Coordinate del click relative all'immagine
+ const imgX = e.clientX - imgRect.left;
+ const imgY = e.clientY - imgRect.top;
+
+ // Se il click è fuori dall'immagine, non fare nulla
+ if (imgX < 0 || imgY < 0 || imgX > imgRect.width || imgY > imgRect.height) {
+ return;
+ }
+
+ // Verifica se stai ridimensionando
+ if (currentRectangle) {
+ const handleRect = detectResizeHandle(e, currentRectangle);
+ if (handleRect) {
+ isResizing = true;
+ resizingEdge = handleRect;
+ startX = e.clientX;
+ startY = e.clientY;
+ rectStartX = baseX;
+ rectStartY = baseY;
+ rectStartWidth = baseWidth;
+ rectStartHeight = baseHeight;
+ currentRectangle.classList.add('resizing');
+ return;
+ }
+
+ // Verifica se stai spostando il rettangolo
+ if (isClickInsideRectangle(e, currentRectangle, imgRect)) {
+ isDrawing = true;
+ startX = e.clientX;
+ startY = e.clientY;
+ rectStartX = baseX;
+ rectStartY = baseY;
+ return;
+ }
+ }
+
+ // Inizia nuovo rettangolo
+ isDrawing = true;
+ startX = imgX;
+ startY = imgY;
+ baseX = imgX;
+ baseY = imgY;
+ baseWidth = 1;
+ baseHeight = 1;
+
+ createNewRectangle(imgX, imgY, 1, 1);
+}
+
+function handleMouseMove(e) {
+ if (!isDrawing && !isResizing) return;
+ if (!container || !backgroundImage) return;
+
+ const imgRect = backgroundImage.getBoundingClientRect();
+
+ if (isResizing && currentRectangle && resizingEdge) {
+ handleResizeRectangle(e, imgRect);
+ } else if (isDrawing) {
+ const imgX = e.clientX - imgRect.left;
+ const imgY = e.clientY - imgRect.top;
+
+ const width = Math.max(10, Math.abs(imgX - startX));
+ const height = Math.max(10, Math.abs(imgY - startY));
+ const x = Math.min(startX, imgX);
+ const y = Math.min(startY, imgY);
+
+ baseX = x;
+ baseY = y;
+ baseWidth = width;
+ baseHeight = height;
+
+ updateRectanglePosition(x, y, width, height);
+ updateAllDimensions();
+ }
+}
+
+function handleMouseUp(e) {
+ if (isResizing && currentRectangle) {
+ currentRectangle.classList.remove('resizing');
+ }
+ isDrawing = false;
+ isResizing = false;
+ resizingEdge = null;
+}
+
+function createNewRectangle(x, y, width, height) {
+ // Rimuovi il vecchio rettangolo se esiste
+ if (currentRectangle) {
+ currentRectangle.remove();
+ }
+
+ const rect = document.createElement('div');
+ rect.className = `rectangle border-${borderPosition}`;
+ rect.style.left = x + 'px';
+ rect.style.top = y + 'px';
+ rect.style.width = width + 'px';
+ rect.style.height = height + 'px';
+ rect.style.borderWidth = currentBorderWidth + 'px';
+
+ // Aggiungi resize handles
+ ['top-left', 'top-right', 'bottom-left', 'bottom-right'].forEach(position => {
+ const handle = document.createElement('div');
+ handle.className = `resize-handle ${position}`;
+ rect.appendChild(handle);
+ });
+
+ container.appendChild(rect);
+ currentRectangle = rect;
+ rectangleExists = true;
+
+ // Abilita bottoni
+ enableControls();
+ updateAllDimensions();
+}
+
+function updateRectanglePosition(x, y, width, height) {
+ if (!currentRectangle) return;
+
+ currentRectangle.style.left = x + 'px';
+ currentRectangle.style.top = y + 'px';
+ currentRectangle.style.width = width + 'px';
+ currentRectangle.style.height = height + 'px';
+}
+
+function handleResizeRectangle(e, imgRect) {
+ if (!currentRectangle) return;
+
+ const deltaX = e.clientX - startX;
+ const deltaY = e.clientY - startY;
+
+ let newX = rectStartX;
+ let newY = rectStartY;
+ let newWidth = rectStartWidth;
+ let newHeight = rectStartHeight;
+
+ // Handle diverse per lato di resize
+ if (resizingEdge.includes('left')) {
+ newX = Math.max(0, rectStartX + deltaX);
+ newWidth = rectStartWidth - (newX - rectStartX);
+ }
+ if (resizingEdge.includes('right')) {
+ newWidth = Math.max(10, rectStartWidth + deltaX);
+ }
+ if (resizingEdge.includes('top')) {
+ newY = Math.max(0, rectStartY + deltaY);
+ newHeight = rectStartHeight - (newY - rectStartY);
+ }
+ if (resizingEdge.includes('bottom')) {
+ newHeight = Math.max(10, rectStartHeight + deltaY);
+ }
+
+ newWidth = Math.max(10, newWidth);
+ newHeight = Math.max(10, newHeight);
+
+ baseX = newX;
+ baseY = newY;
+ baseWidth = newWidth;
+ baseHeight = newHeight;
+
+ updateRectanglePosition(newX, newY, newWidth, newHeight);
+ updateAllDimensions();
+}
+
+function isClickInsideRectangle(e, rect, imgRect) {
+ const rectBounds = rect.getBoundingClientRect();
+ return e.clientX >= rectBounds.left &&
+ e.clientX <= rectBounds.right &&
+ e.clientY >= rectBounds.top &&
+ e.clientY <= rectBounds.bottom;
+}
+
+function detectResizeHandle(e, rect) {
+ const handles = rect.querySelectorAll('.resize-handle');
+ for (let handle of handles) {
+ const handleRect = handle.getBoundingClientRect();
+ const distance = Math.sqrt(
+ Math.pow(e.clientX - (handleRect.left + handleRect.width / 2), 2) +
+ Math.pow(e.clientY - (handleRect.top + handleRect.height / 2), 2)
+ );
+ if (distance < 12) {
+ return handle.className.split(' ').find(c => c !== 'resize-handle');
+ }
+ }
+ return null;
+}
+
+// ============================================================================
+// ZOOM CONTROLS
+// ============================================================================
+
+function handleZoomIn() {
+ if (currentRectangle) {
+ currentZoom = Math.min(currentZoom + zoomStep, maxZoom);
+ updateZoom();
+ updateAllDimensions();
+ }
+}
+
+function handleZoomOut() {
+ if (currentRectangle) {
+ currentZoom = Math.max(currentZoom - zoomStep, minZoom);
+ updateZoom();
+ updateAllDimensions();
+ }
+}
+
+function updateZoom() {
+ const zoomPercent = Math.round(currentZoom * 100);
+ zoomValue.textContent = zoomPercent + '%';
+ if (currentRectangle) {
+ currentRectangle.style.transform = `scale(${currentZoom})`;
+ }
+
+ zoomInBtn.disabled = currentZoom >= maxZoom;
+ zoomOutBtn.disabled = currentZoom <= minZoom;
+}
+
+// ============================================================================
+// BORDER CONTROLS
+// ============================================================================
+
+function handleBorderChange(e) {
+ currentBorderWidth = parseInt(e.target.value);
+ borderValue.textContent = currentBorderWidth + 'px';
+
+ if (currentRectangle) {
+ currentRectangle.style.borderWidth = currentBorderWidth + 'px';
+ updateAllDimensions();
+ }
+}
+
+function handleBorderPositionChange(e) {
+ borderPosition = e.target.value;
+
+ if (currentRectangle) {
+ currentRectangle.classList.remove('border-inside', 'border-outside');
+ currentRectangle.classList.add(`border-${borderPosition}`);
+ updateAllDimensions();
+ }
+}
+
+// ============================================================================
+// RESET AND CLEAR
+// ============================================================================
+
+function handleReset() {
+ resetRectangle();
+}
+
+function resetRectangle() {
+ if (currentRectangle) {
+ currentRectangle.remove();
+ currentRectangle = null;
+ }
+
+ rectangleExists = false;
+ currentZoom = 1;
+ baseWidth = 0;
+ baseHeight = 0;
+ baseX = 0;
+ baseY = 0;
+
+ baseCoordinates.innerHTML = 'Clicca e trascina sull\'immagine per disegnare un rettangolo
';
+ dimensionsInfo.style.display = 'none';
+
+ disableControls();
+}
+
+// ============================================================================
+// UPDATE DISPLAY
+// ============================================================================
+
+function updateAllDimensions() {
+ if (!rectangleExists) return;
+
+ // Coordinate base (senza zoom e bordi)
+ const baseInfo = `
+ X: ${Math.round(baseX)} px
+ Y: ${Math.round(baseY)} px
+ Larghezza: ${Math.round(baseWidth)} px
+ Altezza: ${Math.round(baseHeight)} px
+
+ Percentuali:
+ X%: ${((baseX / backgroundImage.width) * 100).toFixed(2)}%
+ Y%: ${((baseY / backgroundImage.height) * 100).toFixed(2)}%
+ W%: ${((baseWidth / backgroundImage.width) * 100).toFixed(2)}%
+ H%: ${((baseHeight / backgroundImage.height) * 100).toFixed(2)}%
+ `;
+
+ baseCoordinates.innerHTML = baseInfo;
+
+ // Mostra info zoom e bordi se zoom > 1 o bordo > 0
+ if (currentZoom > 1 || currentBorderWidth > 0) {
+ const zoomedWidth = baseWidth * currentZoom;
+ const zoomedHeight = baseHeight * currentZoom;
+
+ const dimensionsHTML = `
+ Con Zoom ${Math.round(currentZoom * 100)}%:
+ Larghezza: ${Math.round(zoomedWidth)} px
+ Altezza: ${Math.round(zoomedHeight)} px
+
+ Bordo: ${currentBorderWidth}px (${borderPosition})
+ `;
+
+ currentDimensions.innerHTML = dimensionsHTML;
+ dimensionsInfo.style.display = 'block';
+ } else {
+ dimensionsInfo.style.display = 'none';
+ }
+}
+
+// ============================================================================
+// BUTTON STATES
+// ============================================================================
+
+function enableControls() {
+ zoomInBtn.disabled = false;
+ zoomOutBtn.disabled = false;
+ resetBtn.disabled = false;
+ confirmBtn.disabled = false;
+}
+
+function disableControls() {
+ zoomInBtn.disabled = true;
+ zoomOutBtn.disabled = true;
+ resetBtn.disabled = true;
+ confirmBtn.disabled = true;
+}
+
+// ============================================================================
+// COORDINATE EXPORT
+// ============================================================================
+
+function getCoordinates() {
+ if (!rectangleExists) {
+ return {};
+ }
+
+ return {
+ x: Math.round(baseX),
+ y: Math.round(baseY),
+ width: Math.round(baseWidth),
+ height: Math.round(baseHeight),
+ zoom: currentZoom,
+ borderWidth: currentBorderWidth,
+ borderPosition: borderPosition,
+ };
+}
+
+// ============================================================================
+// ACTION BUTTONS
+// ============================================================================
+
+function handleConfirm() {
+ const coordinates = getCoordinates();
+
+ if (!rectangleExists) {
+ alert('Nessuna selezione effettuata');
+ return;
+ }
+
+ // Invia coordinate al callback
+ if (onConfirmCallback) {
+ onConfirmCallback(coordinates);
+ }
+
+ // Log per debug
+ console.log('Coordinates confirmed:', coordinates);
+}
+
+function handleCancel() {
+ if (onCancelCallback) {
+ onCancelCallback();
+ }
+ window.close();
+}
+
+// ============================================================================
+// PUBLIC API (per integrazione ComfyUI)
+// ============================================================================
+
+window.RegionSelector = {
+ /**
+ * Inizializza il selettore di regioni con callback personalizzati
+ * @param {Function} onConfirm - Callback quando l'utente conferma (riceve coordinates)
+ * @param {Function} onCancel - Callback quando l'utente annulla
+ */
+ init: function(onConfirm, onCancel) {
+ onConfirmCallback = onConfirm;
+ onCancelCallback = onCancel;
+ },
+
+ /**
+ * Ottieni le coordinate attuali della selezione
+ * @returns {Object} Oggetto con x, y, width, height, zoom, borderWidth, borderPosition
+ */
+ getCoordinates: function() {
+ return getCoordinates();
+ },
+
+ /**
+ * Imposta un'immagine da URL
+ * @param {String} imageUrl - URL dell'immagine
+ */
+ setImage: function(imageUrl) {
+ if (currentRectangle) {
+ if (confirm('Cambiando immagine il rettangolo verrà cancellato. Continuare?')) {
+ resetRectangle();
+ } else {
+ return;
+ }
+ }
+ backgroundImage.src = imageUrl;
+ imageName.textContent = 'Immagine caricata';
+ },
+
+ /**
+ * Carica una selezione precedente
+ * @param {Object} coordinates - Oggetto coordinate
+ */
+ loadCoordinates: function(coordinates) {
+ if (!coordinates || !coordinates.x) return;
+
+ baseX = coordinates.x || 0;
+ baseY = coordinates.y || 0;
+ baseWidth = coordinates.width || 100;
+ baseHeight = coordinates.height || 100;
+ currentZoom = coordinates.zoom || 1;
+ currentBorderWidth = coordinates.borderWidth || 3;
+ borderPosition = coordinates.borderPosition || 'inside';
+
+ // Aggiorna UI
+ borderSlider.value = currentBorderWidth;
+ borderValue.textContent = currentBorderWidth + 'px';
+ document.querySelector(`input[value="${borderPosition}"]`).checked = true;
+ updateZoom();
+
+ // Ridisegna
+ createNewRectangle(baseX, baseY, baseWidth, baseHeight);
+ }
+};
diff --git a/web/region_selector_core.js b/web/region_selector_core.js
new file mode 100644
index 0000000..d31b07a
--- /dev/null
+++ b/web/region_selector_core.js
@@ -0,0 +1,584 @@
+/**
+ * Region Selector JavaScript - Logica interazione canvas editor
+ */
+
+// ============================================================================
+// DOM ELEMENTS
+// ============================================================================
+
+const container = document.getElementById('canvas-container');
+const backgroundImage = document.getElementById('background-image');
+const baseCoordinates = document.getElementById('base-coordinates');
+const dimensionsInfo = document.getElementById('dimensions-info');
+const currentDimensions = document.getElementById('current-dimensions');
+const zoomInBtn = document.getElementById('zoom-in-btn');
+const zoomOutBtn = document.getElementById('zoom-out-btn');
+const zoomValue = document.getElementById('zoom-value');
+const borderSlider = document.getElementById('border-slider');
+const borderValue = document.getElementById('border-value');
+const resetBtn = document.getElementById('reset-btn');
+const borderPositionRadios = document.querySelectorAll('input[name="border-position"]');
+const imageUpload = document.getElementById('image-upload');
+const uploadBtn = document.getElementById('upload-btn');
+const imageName = document.getElementById('image-name');
+const confirmBtn = document.getElementById('confirm-btn');
+const cancelBtn = document.getElementById('cancel-btn');
+const closeBtn = document.getElementById('close-btn');
+
+// ============================================================================
+// STATE VARIABLES
+// ============================================================================
+
+let isDrawing = false;
+let isResizing = false;
+let resizingEdge = null;
+let startX = 0;
+let startY = 0;
+let rectStartX = 0;
+let rectStartY = 0;
+let rectStartWidth = 0;
+let rectStartHeight = 0;
+let currentRectangle = null;
+let currentZoom = 1;
+let currentBorderWidth = 3;
+let borderPosition = 'inside';
+let rectangleExists = false;
+
+// Coordinate base (senza zoom e bordi)
+let baseWidth = 0;
+let baseHeight = 0;
+let baseX = 0;
+let baseY = 0;
+
+const defaultBorderWidth = 3;
+const zoomStep = 0.25;
+const minZoom = 0.25;
+const maxZoom = 4;
+
+// Callback per comunicazione con il nodo
+let onConfirmCallback = null;
+let onCancelCallback = null;
+
+// ============================================================================
+// INITIALIZATION
+// ============================================================================
+
+document.addEventListener('DOMContentLoaded', function() {
+ initializeEventListeners();
+});
+
+function initializeEventListeners() {
+ // Upload button
+ uploadBtn.addEventListener('click', handleUploadClick);
+ imageUpload.addEventListener('change', handleImageUpload);
+
+ // Zoom buttons
+ zoomInBtn.addEventListener('click', handleZoomIn);
+ zoomOutBtn.addEventListener('click', handleZoomOut);
+
+ // Border controls
+ borderSlider.addEventListener('input', handleBorderChange);
+ borderPositionRadios.forEach(radio => {
+ radio.addEventListener('change', handleBorderPositionChange);
+ });
+
+ // Reset button
+ resetBtn.addEventListener('click', handleReset);
+
+ // Canvas drawing
+ container.addEventListener('mousedown', handleCanvasMouseDown);
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+
+ // Action buttons
+ confirmBtn.addEventListener('click', handleConfirm);
+ cancelBtn.addEventListener('click', handleCancel);
+ closeBtn.addEventListener('click', handleCancel);
+
+ // Prevent image drag
+ backgroundImage.addEventListener('dragstart', (e) => e.preventDefault());
+}
+
+// ============================================================================
+// FILE UPLOAD
+// ============================================================================
+
+function handleUploadClick() {
+ imageUpload.click();
+}
+
+function handleImageUpload(e) {
+ const file = e.target.files[0];
+ if (file && file.type.startsWith('image/')) {
+ const reader = new FileReader();
+
+ reader.onload = function(event) {
+ backgroundImage.src = event.target.result;
+ imageName.textContent = `Immagine corrente: ${file.name}`;
+
+ if (currentRectangle) {
+ if (confirm('Cambiando immagine il rettangolo verrà cancellato. Continuare?')) {
+ resetRectangle();
+ } else {
+ imageUpload.value = '';
+ return;
+ }
+ }
+ };
+
+ reader.readAsDataURL(file);
+ }
+}
+
+// ============================================================================
+// RECTANGLE DRAWING
+// ============================================================================
+
+function handleCanvasMouseDown(e) {
+ if (e.button !== 0) return; // Solo left click
+ if (!container || !backgroundImage) return;
+
+ const rect = container.getBoundingClientRect();
+ const imgRect = backgroundImage.getBoundingClientRect();
+ const containerRect = container.getBoundingClientRect();
+
+ // Coordinate del click relative all'immagine
+ const imgX = e.clientX - imgRect.left;
+ const imgY = e.clientY - imgRect.top;
+
+ // Se il click è fuori dall'immagine, non fare nulla
+ if (imgX < 0 || imgY < 0 || imgX > imgRect.width || imgY > imgRect.height) {
+ return;
+ }
+
+ // Verifica se stai ridimensionando
+ if (currentRectangle) {
+ const handleRect = detectResizeHandle(e, currentRectangle);
+ if (handleRect) {
+ isResizing = true;
+ resizingEdge = handleRect;
+ startX = e.clientX;
+ startY = e.clientY;
+ rectStartX = baseX;
+ rectStartY = baseY;
+ rectStartWidth = baseWidth;
+ rectStartHeight = baseHeight;
+ currentRectangle.classList.add('resizing');
+ return;
+ }
+
+ // Verifica se stai spostando il rettangolo
+ if (isClickInsideRectangle(e, currentRectangle, imgRect)) {
+ isDrawing = true;
+ startX = e.clientX;
+ startY = e.clientY;
+ rectStartX = baseX;
+ rectStartY = baseY;
+ return;
+ }
+ }
+
+ // Inizia nuovo rettangolo
+ isDrawing = true;
+ startX = imgX;
+ startY = imgY;
+ baseX = imgX;
+ baseY = imgY;
+ baseWidth = 1;
+ baseHeight = 1;
+
+ createNewRectangle(imgX, imgY, 1, 1);
+}
+
+function handleMouseMove(e) {
+ if (!isDrawing && !isResizing) return;
+ if (!container || !backgroundImage) return;
+
+ const imgRect = backgroundImage.getBoundingClientRect();
+
+ if (isResizing && currentRectangle && resizingEdge) {
+ handleResizeRectangle(e, imgRect);
+ } else if (isDrawing) {
+ const imgX = e.clientX - imgRect.left;
+ const imgY = e.clientY - imgRect.top;
+
+ const width = Math.max(10, Math.abs(imgX - startX));
+ const height = Math.max(10, Math.abs(imgY - startY));
+ const x = Math.min(startX, imgX);
+ const y = Math.min(startY, imgY);
+
+ baseX = x;
+ baseY = y;
+ baseWidth = width;
+ baseHeight = height;
+
+ updateRectanglePosition(x, y, width, height);
+ updateAllDimensions();
+ }
+}
+
+function handleMouseUp(e) {
+ if (isResizing && currentRectangle) {
+ currentRectangle.classList.remove('resizing');
+ }
+ isDrawing = false;
+ isResizing = false;
+ resizingEdge = null;
+}
+
+function createNewRectangle(x, y, width, height) {
+ // Rimuovi il vecchio rettangolo se esiste
+ if (currentRectangle) {
+ currentRectangle.remove();
+ }
+
+ const rect = document.createElement('div');
+ rect.className = `rectangle border-${borderPosition}`;
+ rect.style.left = x + 'px';
+ rect.style.top = y + 'px';
+ rect.style.width = width + 'px';
+ rect.style.height = height + 'px';
+ rect.style.borderWidth = currentBorderWidth + 'px';
+
+ // Aggiungi resize handles
+ ['top-left', 'top-right', 'bottom-left', 'bottom-right'].forEach(position => {
+ const handle = document.createElement('div');
+ handle.className = `resize-handle ${position}`;
+ rect.appendChild(handle);
+ });
+
+ container.appendChild(rect);
+ currentRectangle = rect;
+ rectangleExists = true;
+
+ // Abilita bottoni
+ enableControls();
+ updateAllDimensions();
+}
+
+function updateRectanglePosition(x, y, width, height) {
+ if (!currentRectangle) return;
+
+ currentRectangle.style.left = x + 'px';
+ currentRectangle.style.top = y + 'px';
+ currentRectangle.style.width = width + 'px';
+ currentRectangle.style.height = height + 'px';
+}
+
+function handleResizeRectangle(e, imgRect) {
+ if (!currentRectangle) return;
+
+ const deltaX = e.clientX - startX;
+ const deltaY = e.clientY - startY;
+
+ let newX = rectStartX;
+ let newY = rectStartY;
+ let newWidth = rectStartWidth;
+ let newHeight = rectStartHeight;
+
+ // Handle diverse per lato di resize
+ if (resizingEdge.includes('left')) {
+ newX = Math.max(0, rectStartX + deltaX);
+ newWidth = rectStartWidth - (newX - rectStartX);
+ }
+ if (resizingEdge.includes('right')) {
+ newWidth = Math.max(10, rectStartWidth + deltaX);
+ }
+ if (resizingEdge.includes('top')) {
+ newY = Math.max(0, rectStartY + deltaY);
+ newHeight = rectStartHeight - (newY - rectStartY);
+ }
+ if (resizingEdge.includes('bottom')) {
+ newHeight = Math.max(10, rectStartHeight + deltaY);
+ }
+
+ newWidth = Math.max(10, newWidth);
+ newHeight = Math.max(10, newHeight);
+
+ baseX = newX;
+ baseY = newY;
+ baseWidth = newWidth;
+ baseHeight = newHeight;
+
+ updateRectanglePosition(newX, newY, newWidth, newHeight);
+ updateAllDimensions();
+}
+
+function isClickInsideRectangle(e, rect, imgRect) {
+ const rectBounds = rect.getBoundingClientRect();
+ return e.clientX >= rectBounds.left &&
+ e.clientX <= rectBounds.right &&
+ e.clientY >= rectBounds.top &&
+ e.clientY <= rectBounds.bottom;
+}
+
+function detectResizeHandle(e, rect) {
+ const handles = rect.querySelectorAll('.resize-handle');
+ for (let handle of handles) {
+ const handleRect = handle.getBoundingClientRect();
+ const distance = Math.sqrt(
+ Math.pow(e.clientX - (handleRect.left + handleRect.width / 2), 2) +
+ Math.pow(e.clientY - (handleRect.top + handleRect.height / 2), 2)
+ );
+ if (distance < 12) {
+ return handle.className.split(' ').find(c => c !== 'resize-handle');
+ }
+ }
+ return null;
+}
+
+// ============================================================================
+// ZOOM CONTROLS
+// ============================================================================
+
+function handleZoomIn() {
+ if (currentRectangle) {
+ currentZoom = Math.min(currentZoom + zoomStep, maxZoom);
+ updateZoom();
+ updateAllDimensions();
+ }
+}
+
+function handleZoomOut() {
+ if (currentRectangle) {
+ currentZoom = Math.max(currentZoom - zoomStep, minZoom);
+ updateZoom();
+ updateAllDimensions();
+ }
+}
+
+function updateZoom() {
+ const zoomPercent = Math.round(currentZoom * 100);
+ zoomValue.textContent = zoomPercent + '%';
+ if (currentRectangle) {
+ currentRectangle.style.transform = `scale(${currentZoom})`;
+ }
+
+ zoomInBtn.disabled = currentZoom >= maxZoom;
+ zoomOutBtn.disabled = currentZoom <= minZoom;
+}
+
+// ============================================================================
+// BORDER CONTROLS
+// ============================================================================
+
+function handleBorderChange(e) {
+ currentBorderWidth = parseInt(e.target.value);
+ borderValue.textContent = currentBorderWidth + 'px';
+
+ if (currentRectangle) {
+ currentRectangle.style.borderWidth = currentBorderWidth + 'px';
+ updateAllDimensions();
+ }
+}
+
+function handleBorderPositionChange(e) {
+ borderPosition = e.target.value;
+
+ if (currentRectangle) {
+ currentRectangle.classList.remove('border-inside', 'border-outside');
+ currentRectangle.classList.add(`border-${borderPosition}`);
+ updateAllDimensions();
+ }
+}
+
+// ============================================================================
+// RESET AND CLEAR
+// ============================================================================
+
+function handleReset() {
+ resetRectangle();
+}
+
+function resetRectangle() {
+ if (currentRectangle) {
+ currentRectangle.remove();
+ currentRectangle = null;
+ }
+
+ rectangleExists = false;
+ currentZoom = 1;
+ baseWidth = 0;
+ baseHeight = 0;
+ baseX = 0;
+ baseY = 0;
+
+ baseCoordinates.innerHTML = 'Clicca e trascina sull\'immagine per disegnare un rettangolo
';
+ dimensionsInfo.style.display = 'none';
+
+ disableControls();
+}
+
+// ============================================================================
+// UPDATE DISPLAY
+// ============================================================================
+
+function updateAllDimensions() {
+ if (!rectangleExists) return;
+
+ // Coordinate base (senza zoom e bordi)
+ const baseInfo = `
+ X: ${Math.round(baseX)} px
+ Y: ${Math.round(baseY)} px
+ Larghezza: ${Math.round(baseWidth)} px
+ Altezza: ${Math.round(baseHeight)} px
+
+ Percentuali:
+ X%: ${((baseX / backgroundImage.width) * 100).toFixed(2)}%
+ Y%: ${((baseY / backgroundImage.height) * 100).toFixed(2)}%
+ W%: ${((baseWidth / backgroundImage.width) * 100).toFixed(2)}%
+ H%: ${((baseHeight / backgroundImage.height) * 100).toFixed(2)}%
+ `;
+
+ baseCoordinates.innerHTML = baseInfo;
+
+ // Mostra info zoom e bordi se zoom > 1 o bordo > 0
+ if (currentZoom > 1 || currentBorderWidth > 0) {
+ const zoomedWidth = baseWidth * currentZoom;
+ const zoomedHeight = baseHeight * currentZoom;
+
+ const dimensionsHTML = `
+ Con Zoom ${Math.round(currentZoom * 100)}%:
+ Larghezza: ${Math.round(zoomedWidth)} px
+ Altezza: ${Math.round(zoomedHeight)} px
+
+ Bordo: ${currentBorderWidth}px (${borderPosition})
+ `;
+
+ currentDimensions.innerHTML = dimensionsHTML;
+ dimensionsInfo.style.display = 'block';
+ } else {
+ dimensionsInfo.style.display = 'none';
+ }
+}
+
+// ============================================================================
+// BUTTON STATES
+// ============================================================================
+
+function enableControls() {
+ zoomInBtn.disabled = false;
+ zoomOutBtn.disabled = false;
+ resetBtn.disabled = false;
+ confirmBtn.disabled = false;
+}
+
+function disableControls() {
+ zoomInBtn.disabled = true;
+ zoomOutBtn.disabled = true;
+ resetBtn.disabled = true;
+ confirmBtn.disabled = true;
+}
+
+// ============================================================================
+// COORDINATE EXPORT
+// ============================================================================
+
+function getCoordinates() {
+ if (!rectangleExists) {
+ return {};
+ }
+
+ return {
+ x: Math.round(baseX),
+ y: Math.round(baseY),
+ width: Math.round(baseWidth),
+ height: Math.round(baseHeight),
+ zoom: currentZoom,
+ borderWidth: currentBorderWidth,
+ borderPosition: borderPosition,
+ };
+}
+
+// ============================================================================
+// ACTION BUTTONS
+// ============================================================================
+
+function handleConfirm() {
+ const coordinates = getCoordinates();
+
+ if (!rectangleExists) {
+ alert('Nessuna selezione effettuata');
+ return;
+ }
+
+ // Invia coordinate al callback
+ if (onConfirmCallback) {
+ onConfirmCallback(coordinates);
+ }
+
+ // Log per debug
+ console.log('Coordinates confirmed:', coordinates);
+}
+
+function handleCancel() {
+ if (onCancelCallback) {
+ onCancelCallback();
+ }
+ // Non chiudere la finestra - il callback si occuperà di chiudere il modal
+}
+
+// ============================================================================
+// PUBLIC API (per integrazione ComfyUI)
+// ============================================================================
+
+window.RegionSelector = {
+ /**
+ * Inizializza il selettore di regioni con callback personalizzati
+ * @param {Function} onConfirm - Callback quando l'utente conferma (riceve coordinates)
+ * @param {Function} onCancel - Callback quando l'utente annulla
+ */
+ init: function(onConfirm, onCancel) {
+ onConfirmCallback = onConfirm;
+ onCancelCallback = onCancel;
+ },
+
+ /**
+ * Ottieni le coordinate attuali della selezione
+ * @returns {Object} Oggetto con x, y, width, height, zoom, borderWidth, borderPosition
+ */
+ getCoordinates: function() {
+ return getCoordinates();
+ },
+
+ /**
+ * Imposta un'immagine da URL
+ * @param {String} imageUrl - URL dell'immagine
+ */
+ setImage: function(imageUrl) {
+ if (currentRectangle) {
+ if (confirm('Cambiando immagine il rettangolo verrà cancellato. Continuare?')) {
+ resetRectangle();
+ } else {
+ return;
+ }
+ }
+ backgroundImage.src = imageUrl;
+ imageName.textContent = 'Immagine caricata';
+ },
+
+ /**
+ * Carica una selezione precedente
+ * @param {Object} coordinates - Oggetto coordinate
+ */
+ loadCoordinates: function(coordinates) {
+ if (!coordinates || !coordinates.x) return;
+
+ baseX = coordinates.x || 0;
+ baseY = coordinates.y || 0;
+ baseWidth = coordinates.width || 100;
+ baseHeight = coordinates.height || 100;
+ currentZoom = coordinates.zoom || 1;
+ currentBorderWidth = coordinates.borderWidth || 3;
+ borderPosition = coordinates.borderPosition || 'inside';
+
+ // Aggiorna UI
+ borderSlider.value = currentBorderWidth;
+ borderValue.textContent = currentBorderWidth + 'px';
+ document.querySelector(`input[value="${borderPosition}"]`).checked = true;
+ updateZoom();
+
+ // Ridisegna
+ createNewRectangle(baseX, baseY, baseWidth, baseHeight);
+ }
+};
diff --git a/web/region_selector_extension.js b/web/region_selector_extension.js
new file mode 100644
index 0000000..1e7b1bf
--- /dev/null
+++ b/web/region_selector_extension.js
@@ -0,0 +1,804 @@
+/**
+ * Region Selector Extension - Integrazione nativa con ComfyUI Dialog
+ * Mantiene il bottone nel nodo e apre un dialog nativo
+ */
+
+import { app } from "../../scripts/app.js";
+import { ComfyDialog } from "../../scripts/ui.js";
+
+console.log("[RegionSelectorExt] Loading extension...");
+
+/**
+ * Canvas Selector - Funzionalità complete di selezione rettangoli
+ * Basato sul codice di canva_html
+ */
+
+function initializeCanvasSelector(container, imageUrl) {
+ console.log("[CanvasSelector] Initializing with image:", imageUrl);
+
+ // Riferimenti agli elementi DOM (cercati dentro il container)
+ const canvasContainer = container.querySelector('#canvas-container');
+ const backgroundImage = container.querySelector('#background-image');
+ const baseCoordinates = container.querySelector('#base-coordinates');
+ const dimensionsInfo = container.querySelector('#dimensions-info');
+ const currentDimensions = container.querySelector('#current-dimensions');
+ const zoomInBtn = container.querySelector('#zoom-in-btn');
+ const zoomOutBtn = container.querySelector('#zoom-out-btn');
+ const zoomValue = container.querySelector('#zoom-value');
+ const borderSlider = container.querySelector('#border-slider');
+ const borderValue = container.querySelector('#border-value');
+ const resetBtn = container.querySelector('#reset-btn');
+ const borderPositionRadios = container.querySelectorAll('input[name="border-position"]');
+ const imageUpload = container.querySelector('#image-upload');
+ const uploadBtn = container.querySelector('#upload-btn');
+ const imageName = container.querySelector('#image-name');
+
+ // Variabili di stato
+ let isDrawing = false;
+ let isResizing = false;
+ let resizingEdge = null;
+ let startX = 0;
+ let startY = 0;
+ let rectStartX = 0;
+ let rectStartY = 0;
+ let rectStartWidth = 0;
+ let rectStartHeight = 0;
+ let currentRectangle = null;
+ let currentBorderWidth = 0;
+ let borderPosition = 'inside';
+ let rectangleExists = false;
+
+ // Dimensioni base
+ let baseWidth = 0;
+ let baseHeight = 0;
+ let baseX = 0;
+ let baseY = 0;
+
+ const defaultBorderWidth = 3;
+
+ // Carica l'immagine iniziale
+ if (imageUrl) {
+ backgroundImage.src = imageUrl;
+ const filename = imageUrl.split('/').pop();
+ imageName.textContent = `Image: ${filename}`;
+ }
+
+ // Disabilita drag dell'immagine
+ backgroundImage.addEventListener('dragstart', (e) => e.preventDefault());
+ backgroundImage.style.userSelect = 'none';
+ canvasContainer.style.cursor = 'crosshair';
+
+ // Border slider - removed from UI, using default values
+ // currentBorderWidth = defaultBorderWidth (already set to 3)
+
+ // Border position - removed from UI, using default 'inside'
+ // borderPosition = 'inside' (already set)
+
+ // Reset button
+ resetBtn.addEventListener('click', () => {
+ if (confirm('Clear selection and draw a new one?')) {
+ resetRectangle();
+ }
+ });
+
+ // Mouse down - inizio disegno
+ canvasContainer.addEventListener('mousedown', (e) => {
+ if (rectangleExists) return;
+ if (e.target.classList.contains('resize-handle')) return;
+
+ const rect = canvasContainer.getBoundingClientRect();
+ startX = e.clientX - rect.left;
+ startY = e.clientY - rect.top;
+
+ isDrawing = true;
+
+ currentRectangle = document.createElement('div');
+ currentRectangle.className = 'rectangle';
+
+ if (borderPosition === 'outside') {
+ currentRectangle.classList.add('border-outside');
+ currentRectangle.style.outlineWidth = currentBorderWidth + 'px';
+ currentRectangle.style.outlineStyle = 'solid';
+ currentRectangle.style.borderWidth = '0px';
+ } else {
+ currentRectangle.classList.add('border-inside');
+ currentRectangle.style.borderWidth = currentBorderWidth + 'px';
+ }
+
+ currentRectangle.style.left = startX + 'px';
+ currentRectangle.style.top = startY + 'px';
+ currentRectangle.style.width = '0px';
+ currentRectangle.style.height = '0px';
+
+ if (currentBorderWidth > defaultBorderWidth) {
+ currentRectangle.classList.add('thick-border');
+ }
+
+ canvasContainer.appendChild(currentRectangle);
+ });
+
+ // Mouse move
+ document.addEventListener('mousemove', (e) => {
+ if (isDrawing) {
+ const rect = canvasContainer.getBoundingClientRect();
+ const currentX = e.clientX - rect.left;
+ const currentY = e.clientY - rect.top;
+
+ const width = currentX - startX;
+ const height = currentY - startY;
+
+ if (width < 0) {
+ currentRectangle.style.left = currentX + 'px';
+ currentRectangle.style.width = Math.abs(width) + 'px';
+ } else {
+ currentRectangle.style.width = width + 'px';
+ }
+
+ if (height < 0) {
+ currentRectangle.style.top = currentY + 'px';
+ currentRectangle.style.height = Math.abs(height) + 'px';
+ } else {
+ currentRectangle.style.height = height + 'px';
+ }
+ }
+
+ if (isResizing && currentRectangle) {
+ const deltaX = e.clientX - startX;
+ const deltaY = e.clientY - startY;
+
+ switch(resizingEdge) {
+ case 'top':
+ const newTop = rectStartY + deltaY;
+ const newHeight = rectStartHeight - deltaY;
+ if (newHeight > 0) {
+ currentRectangle.style.top = newTop + 'px';
+ currentRectangle.style.height = newHeight + 'px';
+ }
+ break;
+
+ case 'bottom':
+ const bottomHeight = rectStartHeight + deltaY;
+ if (bottomHeight > 0) {
+ currentRectangle.style.height = bottomHeight + 'px';
+ }
+ break;
+
+ case 'left':
+ const newLeft = rectStartX + deltaX;
+ const newWidth = rectStartWidth - deltaX;
+ if (newWidth > 0) {
+ currentRectangle.style.left = newLeft + 'px';
+ currentRectangle.style.width = newWidth + 'px';
+ }
+ break;
+
+ case 'right':
+ const rightWidth = rectStartWidth + deltaX;
+ if (rightWidth > 0) {
+ currentRectangle.style.width = rightWidth + 'px';
+ }
+ break;
+
+ case 'top-left':
+ const tlNewTop = rectStartY + deltaY;
+ const tlNewLeft = rectStartX + deltaX;
+ const tlNewHeight = rectStartHeight - deltaY;
+ const tlNewWidth = rectStartWidth - deltaX;
+ if (tlNewHeight > 0 && tlNewWidth > 0) {
+ currentRectangle.style.top = tlNewTop + 'px';
+ currentRectangle.style.left = tlNewLeft + 'px';
+ currentRectangle.style.height = tlNewHeight + 'px';
+ currentRectangle.style.width = tlNewWidth + 'px';
+ }
+ break;
+
+ case 'top-right':
+ const trNewTop = rectStartY + deltaY;
+ const trNewHeight = rectStartHeight - deltaY;
+ const trNewWidth = rectStartWidth + deltaX;
+ if (trNewHeight > 0 && trNewWidth > 0) {
+ currentRectangle.style.top = trNewTop + 'px';
+ currentRectangle.style.height = trNewHeight + 'px';
+ currentRectangle.style.width = trNewWidth + 'px';
+ }
+ break;
+
+ case 'bottom-left':
+ const blNewLeft = rectStartX + deltaX;
+ const blNewHeight = rectStartHeight + deltaY;
+ const blNewWidth = rectStartWidth - deltaX;
+ if (blNewHeight > 0 && blNewWidth > 0) {
+ currentRectangle.style.left = blNewLeft + 'px';
+ currentRectangle.style.height = blNewHeight + 'px';
+ currentRectangle.style.width = blNewWidth + 'px';
+ }
+ break;
+
+ case 'bottom-right':
+ const brNewWidth = rectStartWidth + deltaX;
+ const brNewHeight = rectStartHeight + deltaY;
+ if (brNewHeight > 0 && brNewWidth > 0) {
+ currentRectangle.style.width = brNewWidth + 'px';
+ currentRectangle.style.height = brNewHeight + 'px';
+ }
+ break;
+ }
+
+ baseX = parseFloat(currentRectangle.style.left);
+ baseY = parseFloat(currentRectangle.style.top);
+ baseWidth = parseFloat(currentRectangle.style.width);
+ baseHeight = parseFloat(currentRectangle.style.height);
+
+ updateAllDimensions();
+ }
+ });
+
+ // Mouse up
+ document.addEventListener('mouseup', (e) => {
+ if (isDrawing) {
+ const rect = canvasContainer.getBoundingClientRect();
+ const endX = e.clientX - rect.left;
+ const endY = e.clientY - rect.top;
+
+ const x1 = Math.min(startX, endX);
+ const y1 = Math.min(startY, endY);
+ const x2 = Math.max(startX, endX);
+ const y2 = Math.max(startY, endY);
+
+ baseX = x1;
+ baseY = y1;
+ baseWidth = x2 - x1;
+ baseHeight = y2 - y1;
+
+ rectangleExists = true;
+ currentRectangle.classList.add('complete');
+
+ canvasContainer.classList.add('drawing-disabled');
+ canvasContainer.style.cursor = 'default';
+
+ resetBtn.disabled = false;
+
+ dimensionsInfo.style.display = 'block';
+
+ addResizeHandles();
+ updateAllDimensions();
+
+ isDrawing = false;
+ }
+
+ if (isResizing) {
+ isResizing = false;
+ resizingEdge = null;
+ canvasContainer.style.cursor = 'default';
+ }
+ });
+
+ function resetRectangle() {
+ if (currentRectangle) {
+ currentRectangle.remove();
+ }
+ currentRectangle = null;
+ rectangleExists = false;
+ canvasContainer.classList.remove('drawing-disabled');
+ canvasContainer.style.cursor = 'crosshair';
+ resetBtn.disabled = true;
+ dimensionsInfo.style.display = 'none';
+ baseCoordinates.innerHTML = 'Click and drag to select
';
+ }
+
+ function addResizeHandles() {
+ const handles = ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
+ handles.forEach(position => {
+ const handle = document.createElement('div');
+ handle.className = `resize-handle ${position}`;
+ handle.dataset.edge = position;
+ currentRectangle.appendChild(handle);
+
+ handle.addEventListener('mousedown', (e) => {
+ e.stopPropagation();
+ isResizing = true;
+ resizingEdge = position;
+ startX = e.clientX;
+ startY = e.clientY;
+
+ rectStartX = parseFloat(currentRectangle.style.left);
+ rectStartY = parseFloat(currentRectangle.style.top);
+ rectStartWidth = parseFloat(currentRectangle.style.width);
+ rectStartHeight = parseFloat(currentRectangle.style.height);
+
+ canvasContainer.style.cursor = getComputedStyle(handle).cursor;
+ });
+ });
+ }
+
+ function updateAllDimensions() {
+ if (!currentRectangle) return;
+
+ const baseX1 = baseX;
+ const baseY1 = baseY;
+ const baseX2 = baseX + baseWidth;
+ const baseY2 = baseY + baseHeight;
+
+ baseCoordinates.innerHTML = `
+ 📍 Coordinates:
+ x1 = ${Math.round(baseX1)}px, y1 = ${Math.round(baseY1)}px
+ x2 = ${Math.round(baseX2)}px, y2 = ${Math.round(baseY2)}px
+
+ 📏 Size:
+ Width: ${Math.round(baseWidth)}px
+ Height: ${Math.round(baseHeight)}px
+ `;
+ }
+
+ return {
+ getCoordinates: () => {
+ const baseX1 = baseX;
+ const baseY1 = baseY;
+ const baseX2 = baseX + baseWidth;
+ const baseY2 = baseY + baseHeight;
+
+ let effectiveX1 = baseX1;
+ let effectiveY1 = baseY1;
+ let effectiveX2 = baseX2;
+ let effectiveY2 = baseY2;
+
+ if (borderPosition === 'outside') {
+ effectiveX1 = Math.max(0, baseX1 - currentBorderWidth);
+ effectiveY1 = Math.max(0, baseY1 - currentBorderWidth);
+ effectiveX2 = baseX2 + currentBorderWidth;
+ effectiveY2 = baseY2 + currentBorderWidth;
+ } else {
+ effectiveX1 = baseX1 + currentBorderWidth;
+ effectiveY1 = baseY1 + currentBorderWidth;
+ effectiveX2 = Math.max(effectiveX1 + 1, baseX2 - currentBorderWidth);
+ effectiveY2 = Math.max(effectiveY1 + 1, baseY2 - currentBorderWidth);
+ }
+
+ return {
+ x1: Math.round(effectiveX1),
+ y1: Math.round(effectiveY1),
+ x2: Math.round(effectiveX2),
+ y2: Math.round(effectiveY2),
+ borderWidth: currentBorderWidth,
+ borderPosition: borderPosition,
+ };
+ },
+ getState: () => ({
+ exists: rectangleExists,
+ baseX, baseY, baseWidth, baseHeight,
+ borderWidth: currentBorderWidth,
+ borderPosition
+ })
+ };
+}
+
+window.CanvasSelector = { initializeCanvasSelector };
+
+app.registerExtension({
+ name: "BoxBox.BoxSelectorExtension",
+
+ async setup(app) {
+ console.log("[RegionSelectorExt] Setup called");
+ },
+
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
+ if (nodeData.name !== "BoxSelector") return;
+
+ console.log("[RegionSelectorExt] Registering BoxSelector...");
+
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function () {
+ const r = onNodeCreated?.apply(this, arguments);
+ const node = this;
+
+ console.log("[RegionSelectorExt] Node created, adding button...");
+
+ // Aggiungi bottone nativo ComfyUI
+ this.addWidget("button", "📦 Select Box", null, () => {
+ openRegionDialog(node, app);
+ });
+
+ return r;
+ };
+ },
+});
+
+/**
+ * Apre il dialog del selettore di regioni
+ */
+async function openRegionDialog(node, app) {
+ console.log("[RegionSelectorExt] Opening dialog...");
+ console.log("[RegionSelectorExt] Node inputs:", node.inputs);
+ console.log("[RegionSelectorExt] Node widgets:", node.widgets);
+
+ let imageName = null;
+ let imageUrl = null;
+
+ // Metodo 1: Se c'è un input collegato
+ if (node.inputs && node.inputs[0] && node.inputs[0].link !== undefined && node.inputs[0].link !== null) {
+ const imageInput = node.inputs[0];
+ const link = app.graph.links[imageInput.link];
+
+ if (link) {
+ const sourceNode = app.graph._nodes_by_id[link.origin_id];
+ console.log("[RegionSelectorExt] Source node found:", sourceNode);
+
+ // Esegui il nodo sorgente per ottenere l'immagine
+ try {
+ await executeSourceNode(sourceNode, app);
+
+ // Dopo l'esecuzione, prova a prendere il filename dal nodo sorgente
+ if (sourceNode.widgets) {
+ const filenameWidget = sourceNode.widgets.find(w => w.name === "image");
+ if (filenameWidget && filenameWidget.value) {
+ imageName = filenameWidget.value;
+ console.log("[RegionSelectorExt] Image from source node:", imageName);
+ }
+ }
+ } catch (e) {
+ console.error("[RegionSelectorExt] Error executing source node:", e);
+ }
+ }
+ }
+
+ // Metodo 2: Prova a prendere l'immagine dal widget del nodo stesso
+ if (!imageName) {
+ const imageWidget = node.widgets?.find((w) => w.name === "image");
+ if (imageWidget && imageWidget.value) {
+ imageName = imageWidget.value;
+ console.log("[RegionSelectorExt] Image from widget:", imageName);
+ }
+ }
+
+ if (!imageName) {
+ alert("⚠️ Nessuna immagine collegata al nodo!\n\nCollega un'immagine dal nodo LoadImage.");
+ return;
+ }
+
+ imageUrl = `/view?filename=${imageName}&type=input`;
+ console.log("[RegionSelectorExt] Image URL:", imageUrl);
+
+ // Crea il dialog
+ const dialog = new ComfyDialog();
+ dialog.element.style.width = "95vw";
+ dialog.element.style.height = "95vh";
+ dialog.element.style.maxWidth = "none";
+ dialog.element.style.maxHeight = "none";
+
+ // Crea il contenitore HTML
+ const container = document.createElement("div");
+ container.style.cssText = `
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: white;
+ `;
+
+ // Aggiungi CSS per il rettangolo e i resize handles
+ const styleTag = document.createElement("style");
+ styleTag.textContent = `
+ .rectangle {
+ position: absolute;
+ background-color: rgba(37, 99, 235, 0.05);
+ cursor: move;
+ transition: none;
+ z-index: 10;
+ }
+
+ .rectangle.border-inside {
+ border: 3px solid #2563eb;
+ }
+
+ .rectangle.border-outside {
+ outline: 3px solid #2563eb;
+ outline-offset: 0px;
+ }
+
+ .rectangle.thick-border {
+ box-shadow: 0 0 8px rgba(37, 99, 235, 0.3);
+ }
+
+ .resize-handle {
+ position: absolute;
+ background-color: #2563eb;
+ z-index: 20;
+ opacity: 0.7;
+ transition: opacity 0.2s;
+ }
+
+ .resize-handle:hover {
+ opacity: 1;
+ box-shadow: 0 0 6px rgba(37, 99, 235, 0.8);
+ }
+
+ .resize-handle.top,
+ .resize-handle.bottom {
+ width: 100%;
+ height: 8px;
+ cursor: ns-resize;
+ }
+
+ .resize-handle.top {
+ top: -4px;
+ left: 0;
+ }
+
+ .resize-handle.bottom {
+ bottom: -4px;
+ left: 0;
+ }
+
+ .resize-handle.left,
+ .resize-handle.right {
+ width: 8px;
+ height: 100%;
+ cursor: ew-resize;
+ }
+
+ .resize-handle.left {
+ left: -4px;
+ top: 0;
+ }
+
+ .resize-handle.right {
+ right: -4px;
+ top: 0;
+ }
+
+ .resize-handle.top-left,
+ .resize-handle.bottom-right {
+ width: 12px;
+ height: 12px;
+ cursor: nwse-resize;
+ }
+
+ .resize-handle.top-left {
+ top: -6px;
+ left: -6px;
+ border-radius: 50%;
+ }
+
+ .resize-handle.bottom-right {
+ bottom: -6px;
+ right: -6px;
+ border-radius: 50%;
+ }
+
+ .resize-handle.top-right,
+ .resize-handle.bottom-left {
+ width: 12px;
+ height: 12px;
+ cursor: nesw-resize;
+ }
+
+ .resize-handle.top-right {
+ top: -6px;
+ right: -6px;
+ border-radius: 50%;
+ }
+
+ .resize-handle.bottom-left {
+ bottom: -6px;
+ left: -6px;
+ border-radius: 50%;
+ }
+
+ #canvas-container.drawing-disabled {
+ cursor: default !important;
+ }
+ `;
+ document.head.appendChild(styleTag);
+
+ // Carica l'HTML del selettore
+ const innerHtml = `
+
+
+
+
+
+
+
+
+
Controls
+
+
+
+ Image: ${imageName}
+
+
+
+
+
+
+
+
+
Selection
+
+
Click and drag to select
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+ `;
+
+ container.innerHTML = innerHtml;
+ dialog.element.appendChild(container);
+
+ // Aggiungi i pulsanti d'azione
+ const confirmBtn = document.createElement("button");
+ confirmBtn.textContent = "✅ Confirm";
+ confirmBtn.style.cssText = `
+ background-color: #16a34a;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 600;
+ margin-right: 10px;
+ `;
+
+ const cancelBtn = document.createElement("button");
+ cancelBtn.textContent = "❌ Cancel";
+ cancelBtn.style.cssText = `
+ background-color: #dc2626;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 600;
+ `;
+
+ // Il confirmBtn.onclick sarà impostato dopo l'inizializzazione del canvas_selector
+ confirmBtn.onclick = () => {
+ console.log("[RegionSelectorExt] Confirm button clicked but CanvasSelector not ready yet");
+ alert("Per favore aspetta che il selettore sia caricato");
+ };
+
+ cancelBtn.onclick = () => {
+ console.log("[RegionSelectorExt] Dialog cancelled");
+ dialog.close();
+ };
+
+ dialog.show(confirmBtn, cancelBtn);
+
+ // Inizializza il selettore dopo che il DOM è renderizzato
+ setTimeout(() => {
+ // Carica canvas_selector.js se non è già caricato
+ let attempts = 0;
+ const waitForCanvasSelector = setInterval(() => {
+ attempts++;
+ if (window.CanvasSelector) {
+ clearInterval(waitForCanvasSelector);
+ const selector = window.CanvasSelector.initializeCanvasSelector(container, imageUrl);
+
+ // Aggiorna il confirm button per usare le coordinate reali
+ confirmBtn.onclick = () => {
+ const coords = selector.getCoordinates();
+
+ // Cerca il widget region_metadata tra i widget del nodo
+ // ComfyUI lo crea automaticamente dal INPUT_TYPES opzionale
+ let metadataWidget = node.widgets?.find((w) => w.name === "region_metadata");
+
+ if (metadataWidget) {
+ const metadata = JSON.stringify({
+ ...coords,
+ selected: true
+ });
+ metadataWidget.value = metadata;
+ if (metadataWidget.callback) {
+ metadataWidget.callback(metadata);
+ }
+ console.log("[RegionSelectorExt] Metadata widget updated with:", metadata);
+ } else {
+ console.warn("[RegionSelectorExt] region_metadata widget not found. Available widgets:", node.widgets?.map(w => w.name) || []);
+ }
+
+ console.log("[RegionSelectorExt] Coordinates saved:", coords);
+ dialog.close();
+ };
+ console.log("[RegionSelectorExt] CanvasSelector initialized successfully");
+ } else if (attempts > 50) {
+ clearInterval(waitForCanvasSelector);
+ console.error("[RegionSelectorExt] CanvasSelector failed to load after 5 seconds");
+ alert("Error: Box Selector failed to load. Check browser console for details.");
+ }
+ }, 100);
+ }, 200);
+}
+
+/**
+ * Esegue il nodo sorgente per ottenere l'immagine
+ */
+async function executeSourceNode(sourceNode, app) {
+ try {
+ // Se il nodo sorgente è LoadImage, il widget "image" contiene il filename
+ const imageWidget = sourceNode.widgets?.find(w => w.name === "image");
+ if (imageWidget && imageWidget.value) {
+ console.log("[RegionSelectorExt] Found image in source node:", imageWidget.value);
+ return imageWidget.value;
+ }
+ } catch (e) {
+ console.error("[RegionSelectorExt] Error in executeSourceNode:", e);
+ }
+}
+
+console.log("[RegionSelectorExt] Extension loaded!");
diff --git a/web/region_selector_widget.js b/web/region_selector_widget.js
new file mode 100644
index 0000000..1bb4722
--- /dev/null
+++ b/web/region_selector_widget.js
@@ -0,0 +1,302 @@
+/**
+ * Region Selector Widget - Integrazione con ComfyUI
+ * Aggiunge un bottone al nodo per aprire il selettore di regioni
+ */
+
+import { app } from "../../scripts/app.js";
+import { api } from "../../scripts/api.js";
+
+app.registerExtension({
+ name: "ImageRegionSelect.RegionSelector",
+
+ /**
+ * Quando un nodo viene creato, aggiungi il widget personalizzato
+ */
+ async nodeCreated(node, app) {
+ // Esegui solo per il nodo RegionSelectorNode
+ if (node.comfyClass !== "RegionSelectorNode") return;
+
+ // Trova i widget del nodo
+ const widthWidget = node.widgets.find(w => w.name === "image_width");
+ const heightWidget = node.widgets.find(w => w.name === "image_height");
+ const coordinatesWidget = node.widgets.find(w => w.name === "coordinates_json");
+
+ // Crea bottone per aprire il selettore
+ const selectorButton = document.createElement("button");
+ selectorButton.textContent = "🎯 Apri Selettore Regioni";
+ selectorButton.style.cssText = `
+ width: 100%;
+ padding: 10px;
+ margin: 5px 0;
+ background-color: #2563eb;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ `;
+
+ selectorButton.addEventListener("mouseover", () => {
+ selectorButton.style.backgroundColor = "#1d4ed8";
+ });
+
+ selectorButton.addEventListener("mouseout", () => {
+ selectorButton.style.backgroundColor = "#2563eb";
+ });
+
+ selectorButton.addEventListener("click", () => {
+ openRegionSelector(node, widthWidget, heightWidget, coordinatesWidget);
+ });
+
+ // Aggiungi il bottone al nodo
+ const container = document.createElement("div");
+ container.style.marginBottom = "10px";
+ container.appendChild(selectorButton);
+
+ // Inserisci prima del primo widget
+ if (node.widgets.length > 0) {
+ node.widgets[0].element?.parentNode?.insertBefore(container, node.widgets[0].element);
+ }
+
+ // Aggiungi anche come custom widget per miglior tracking
+ node.regionSelectorButton = selectorButton;
+ node.regionSelectorContainer = container;
+
+ // Aggiungi etichetta informativa
+ const infoLabel = document.createElement("div");
+ infoLabel.style.cssText = `
+ font-size: 11px;
+ color: #64748b;
+ padding: 5px 0;
+ margin: 5px 0;
+ text-align: center;
+ `;
+ infoLabel.textContent = "Apri l'interfaccia per selezionare le coordinate della regione";
+ container.appendChild(infoLabel);
+ },
+
+ /**
+ * Hook che si chiama quando il nodo viene caricato
+ */
+ async loadedGraphNode(node, app) {
+ if (node.comfyClass !== "RegionSelectorNode") return;
+
+ // Ripristina lo stato del bottone se necessario
+ const coordinatesWidget = node.widgets.find(w => w.name === "coordinates_json");
+ if (coordinatesWidget && coordinatesWidget.value && coordinatesWidget.value !== "{}") {
+ console.log("Region coordinates loaded:", coordinatesWidget.value);
+ }
+ }
+});
+
+/**
+ * Apre l'interfaccia selettore di regioni
+ */
+function openRegionSelector(node, widthWidget, heightWidget, coordinatesWidget) {
+ // Carica l'HTML dell'interfaccia
+ const htmlPath = "extensions/ImageRegionSelect/region_selector.html";
+
+ fetch(htmlPath)
+ .then(response => {
+ if (!response.ok) throw new Error(`Failed to load ${htmlPath}`);
+ return response.text();
+ })
+ .then(html => {
+ // Crea una finestra modale
+ const modal = createModal(html, node, widthWidget, heightWidget, coordinatesWidget);
+ document.body.appendChild(modal);
+ })
+ .catch(error => {
+ console.error("Error loading region selector:", error);
+ alert("Errore nel caricamento del selettore di regioni: " + error.message);
+ });
+}
+
+/**
+ * Crea una finestra modale con il selettore
+ */
+function createModal(html, node, widthWidget, heightWidget, coordinatesWidget) {
+ const modal = document.createElement("div");
+ modal.id = "region-selector-modal";
+ modal.style.cssText = `
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(2px);
+ `;
+
+ // Container principale
+ const container = document.createElement("div");
+ container.style.cssText = `
+ width: 90vw;
+ height: 90vh;
+ background-color: white;
+ border-radius: 12px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ `;
+
+ // Inserisci l'HTML del selettore
+ container.innerHTML = html;
+
+ modal.appendChild(container);
+
+ // Attendi che il DOM sia pronto
+ setTimeout(() => {
+ // Inizializza il selettore con i callback
+ if (window.RegionSelector) {
+ window.RegionSelector.init(
+ (coordinates) => handleCoordinatesConfirmed(coordinates, modal, coordinatesWidget, node),
+ () => handleSelectorCancel(modal)
+ );
+
+ // Carica coordinate precedenti se esistono
+ if (coordinatesWidget && coordinatesWidget.value && coordinatesWidget.value !== "{}") {
+ try {
+ const previousCoordinates = JSON.parse(coordinatesWidget.value);
+ if (previousCoordinates.x !== undefined) {
+ window.RegionSelector.loadCoordinates(previousCoordinates);
+ }
+ } catch (e) {
+ console.warn("Could not load previous coordinates:", e);
+ }
+ }
+ }
+
+ // Aggiorna i bottoni di chiusura
+ const closeBtn = document.getElementById("close-btn");
+ const cancelBtn = document.getElementById("cancel-btn");
+
+ if (closeBtn) {
+ closeBtn.addEventListener("click", () => handleSelectorCancel(modal));
+ }
+ if (cancelBtn) {
+ cancelBtn.addEventListener("click", () => handleSelectorCancel(modal));
+ }
+
+ // Precarica l'immagine di sfondo (opzionale)
+ loadBackgroundImage(node, widthWidget, heightWidget);
+
+ }, 50);
+
+ return modal;
+}
+
+/**
+ * Gestisce la conferma delle coordinate
+ */
+function handleCoordinatesConfirmed(coordinates, modal, coordinatesWidget, node) {
+ // Salva le coordinate nel widget JSON
+ coordinatesWidget.value = JSON.stringify(coordinates);
+
+ // Forza l'aggiornamento del nodo
+ if (node.widgets) {
+ // Trigga callback se esiste
+ if (coordinatesWidget.callback) {
+ coordinatesWidget.callback(coordinatesWidget.value);
+ }
+ }
+
+ // Log per debug
+ console.log("Region coordinates confirmed and saved:", coordinates);
+
+ // Mostra messaggio di successo
+ showNotification("✅ Selezione confermata!", "success");
+
+ // Chiudi la modale
+ modal.remove();
+}
+
+/**
+ * Gestisce l'annullamento
+ */
+function handleSelectorCancel(modal) {
+ modal.remove();
+ console.log("Region selector cancelled");
+}
+
+/**
+ * Carica un'immagine come sfondo nell'interfaccia
+ * (Opzionale - per preview)
+ */
+function loadBackgroundImage(node, widthWidget, heightWidget) {
+ try {
+ // Cerca un nodo con input immagine nel flusso
+ // Per ora, usa l'immagine default di picsum.photos
+ const bgImg = document.getElementById("background-image");
+ if (bgImg) {
+ // Opzionalmente puoi aggiungere logica per caricare immagini
+ console.log("Image loader ready");
+ }
+ } catch (e) {
+ console.warn("Could not auto-load image:", e);
+ }
+}
+
+/**
+ * Mostra una notifica temporanea
+ */
+function showNotification(message, type = "info") {
+ const notification = document.createElement("div");
+ notification.textContent = message;
+ notification.style.cssText = `
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ padding: 12px 20px;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ z-index: 20000;
+ animation: slideIn 0.3s ease;
+ ${type === "success" ? "background-color: #16a34a; color: white;" :
+ type === "error" ? "background-color: #dc2626; color: white;" :
+ "background-color: #2563eb; color: white;"}
+ `;
+
+ document.body.appendChild(notification);
+
+ // Auto-remove dopo 3 secondi
+ setTimeout(() => {
+ notification.style.animation = "slideOut 0.3s ease";
+ setTimeout(() => notification.remove(), 300);
+ }, 3000);
+}
+
+// Aggiungi animazioni CSS
+const style = document.createElement("style");
+style.textContent = `
+ @keyframes slideIn {
+ from {
+ transform: translateX(400px);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ }
+
+ @keyframes slideOut {
+ from {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ to {
+ transform: translateX(400px);
+ opacity: 0;
+ }
+ }
+`;
+document.head.appendChild(style);