Added experimental node - Rayko Text Overlay

This commit is contained in:
Raykosan
2026-03-23 03:29:21 +03:00
parent dd537d90be
commit f08409fa94
6 changed files with 1111 additions and 44 deletions
+4 -21
View File
@@ -54,8 +54,6 @@ class RaykoIntermediateSplineMask:
else:
PENDING_DECISIONS[unique_id]["coordinates"] = coordinates
print(f"[InSPLINE 🦊] Node {unique_id} waiting for approval...")
if prompt_id is None:
import uuid
prompt_id = str(uuid.uuid4())
@@ -84,14 +82,12 @@ class RaykoIntermediateSplineMask:
current_coordinates = state.get("coordinates", coordinates)
if current_status == "approved":
print(f"[InSPLINE 🦊] Node {unique_id} APPROVED!")
coordinates = current_coordinates
if unique_id in PENDING_DECISIONS:
del PENDING_DECISIONS[unique_id]
break
if current_status == "cancelled":
print(f"[InSPLINE 🦊] Node {unique_id} CANCELLED!")
if unique_id in PENDING_DECISIONS:
del PENDING_DECISIONS[unique_id]
break
@@ -105,14 +101,11 @@ class RaykoIntermediateSplineMask:
if current_status == "rejected":
PENDING_DECISIONS[unique_id]["status"] = "pending"
coordinates = current_coordinates
print(f"[InSPLINE 🦊] Node {unique_id} rejected, waiting for new points...")
time.sleep(0.3)
else:
print("[InSPLINE 🦊] Server not available, skipping pause logic.")
print(f"[InSPLINE 🦊] Processing mask with coordinates: {coordinates[:80]}...")
if image is None or len(image) == 0:
h, w = 512, 512
return (torch.zeros((1, h, w, 3)), torch.zeros((1, h, w)))
@@ -134,11 +127,11 @@ class RaykoIntermediateSplineMask:
draw.polygon(pts, fill=255)
mask_np = np.array(pil_mask).astype(np.float32) / 255.0
print(f"[InSPLINE 🦊] ✅ Mask created with {len(pts)} points")
print(f"[InSPLINE 🦊] Mask created with {len(pts)} points")
else:
print(f"[InSPLINE 🦊] ⚠️ Less than 3 points - empty mask")
print(f"[InSPLINE 🦊] Less than 3 points - empty mask")
except Exception as e:
print(f"[InSPLINE 🦊] ❌ Error parsing coordinates: {e}")
print(f"[InSPLINE 🦊] Error parsing coordinates: {e}")
mask_tensor = torch.from_numpy(mask_np).unsqueeze(0)
return (image, mask_tensor)
@@ -153,28 +146,22 @@ if SERVER_AVAILABLE:
decision = data.get("decision")
coordinates = data.get("coordinates")
print(f"[InSPLINE 🦊] API received '{decision}' for node '{node_id}'")
if node_id in PENDING_DECISIONS:
if decision == "approve":
PENDING_DECISIONS[node_id]["status"] = "approved"
if coordinates:
PENDING_DECISIONS[node_id]["coordinates"] = coordinates
print(f"[InSPLINE 🦊] Status: APPROVED")
elif decision == "reject":
PENDING_DECISIONS[node_id]["status"] = "rejected"
if coordinates is not None:
PENDING_DECISIONS[node_id]["coordinates"] = coordinates
print(f"[InSPLINE 🦊] Status: REJECTED (node still waiting)")
elif decision == "cancel":
PENDING_DECISIONS[node_id]["status"] = "cancelled"
print(f"[InSPLINE 🦊] Status: CANCELLED")
return web.Response(status=200, text="Decision recorded")
else:
print(f"[InSPLINE 🦊] Node {node_id} NOT FOUND in PENDING_DECISIONS")
return web.Response(status=404, text=f"Node {node_id} not waiting")
except Exception as e:
@@ -189,14 +176,10 @@ if SERVER_AVAILABLE:
data = await request.json()
node_id = str(data.get("node_id"))
print(f"[InSPLINE 🦊] API received CLEANUP for node '{node_id}'")
if node_id in PENDING_DECISIONS:
PENDING_DECISIONS[node_id]["status"] = "removed"
print(f"[InSPLINE 🦊] Status: REMOVED")
return web.Response(status=200, text="Cleanup recorded")
else:
print(f"[InSPLINE 🦊] Node {node_id} NOT FOUND (already cleaned)")
return web.Response(status=200, text="Node not found")
except Exception as e:
+479
View File
@@ -0,0 +1,479 @@
import os
import torch
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import cv2
import math
import functools
from typing import Dict, Tuple, List, Optional, Any
from dataclasses import dataclass
import json
try:
from comfy.utils import ProgressBar as ComfyProgressBar
HAS_PBAR = True
except ImportError:
HAS_PBAR = False
def ComfyProgressBar(total: int, desc: str = ""):
class DummyPBar:
def update(self, n: int = 1): pass
return DummyPBar()
try:
import folder_paths
HAS_FOLDER_PATHS = True
except ImportError:
HAS_FOLDER_PATHS = False
print("\033[93m🦊\033[0m \033[93mRaykoStudio - RS Text Overlay \033[92mLOADED\033[0m")
class FontNotFoundError(Exception): pass
@dataclass
class TextRenderConfig:
font: ImageFont.ImageFont
text_color: Tuple[int, int, int, int]
outline_color: Tuple[int, int, int, int]
outline_thickness: int
letter_spacing: float
line_spacing: float
text_orientation: str
enable_shadow: bool
shadow_color: Tuple[int, int, int, int]
shadow_offset: Tuple[int, int]
shadow_blur: float
text_opacity: int = 100
@property
def has_outline(self) -> bool:
return self.outline_thickness > 0
@property
def has_shadow(self) -> bool:
return self.enable_shadow and any(self.shadow_offset)
class RS_TextOverlay:
WEB_DIRECTORY = "web"
_font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {}
_color_cache: Dict[str, Tuple[int, int, int, int]] = {}
_text_dim_cache: Dict[Tuple[str, str, float, float, float, int, str], Tuple[float, float, List[float], List[float]]] = {}
_contour_cache: Dict[int, float] = {}
_font_list: List[str] = None
@classmethod
def INPUT_TYPES(cls):
current_dir = os.path.dirname(os.path.abspath(__file__))
fonts_dir = os.path.join(current_dir, "fonts")
font_list = []
default_font_path = os.path.join(fonts_dir, "Arial.ttf")
if os.path.isfile(default_font_path):
font_list.append("default")
if os.path.isdir(fonts_dir):
for f in sorted(os.listdir(fonts_dir)):
if f.lower().endswith(('.ttf', '.otf', '.ttc')):
if f not in font_list:
font_list.append(f)
if HAS_FOLDER_PATHS:
try:
system_font_dirs = folder_paths.get_folder_paths("fonts")
if system_font_dirs:
for font_dir in system_font_dirs:
if os.path.isdir(font_dir):
for f in sorted(os.listdir(font_dir)):
if f.lower().endswith(('.ttf', '.otf', '.ttc')) and f not in font_list:
font_list.append(f)
except:
pass
cls._font_list = font_list
return {
"required": {
"image": ("IMAGE",),
"mask": ("MASK",),
"node_data": ("STRING", {
"default": json.dumps({
"text": "Текст",
"font_name": "default",
"text_color": "#FFFFFF",
"outline_thickness": 0,
"outline_color": "#808080",
"rotate_with_mask": True,
"text_opacity": 1.0,
"line_spacing": 1.0,
"letter_spacing": 0.0,
"text_orientation": "horizontal",
"enable_shadow": False,
"shadow_color": "#333333",
"shadow_offset_x": 2,
"shadow_offset_y": 2,
"shadow_blur": 0.0,
"shadow_opacity": 0.8,
"font_list": font_list
}),
"hidden": True
}),
},
"optional": {}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply_text"
CATEGORY = "🦊 RaykoStudio"
def __init__(self):
self.debug = False
def log(self, message: str, level: int = 1):
if self.debug or level <= 0:
prefix = {0: "❌", 1: "ℹ️", 2: "🔍"}.get(level, "•")
print(f"{prefix} RS_TextOverlay: {message}")
def get_font_path(self, font_name: str) -> Optional[str]:
if font_name == "default":
current_dir = os.path.dirname(os.path.abspath(__file__))
default_path = os.path.join(current_dir, "fonts", "Arial.ttf")
if os.path.isfile(default_path):
return default_path
return None
current_dir = os.path.dirname(os.path.abspath(__file__))
local_path = os.path.join(current_dir, "fonts", font_name)
if os.path.isfile(local_path):
self.log(f"Font found locally: {local_path}", level=2)
return local_path
if HAS_FOLDER_PATHS:
try:
system_font_dirs = folder_paths.get_folder_paths("fonts")
if system_font_dirs:
for font_dir in system_font_dirs:
system_path = os.path.join(font_dir, font_name)
if os.path.isfile(system_path):
self.log(f"Font found in system: {system_path}", level=2)
return system_path
except:
pass
if os.path.isabs(font_name) and os.path.isfile(font_name):
return font_name
self.log(f"Font not found: {font_name}", level=0)
return None
@functools.lru_cache(maxsize=32)
def get_cached_font(self, font_path: str, size: int) -> ImageFont.ImageFont:
cache_key = (font_path, size)
if cache_key in self._font_cache:
return self._font_cache[cache_key]
try:
font = ImageFont.truetype(font_path, size)
self._font_cache[cache_key] = font
return font
except Exception as e:
raise FontNotFoundError(f"Font error: {e}")
def parse_color(self, color: Any, alpha: float = 1.0) -> Tuple[int, int, int, int]:
if color is None:
return (255, 255, 255, int(alpha * 255))
cache_key = (str(color), alpha)
if cache_key in self._color_cache:
return self._color_cache[cache_key]
try:
if isinstance(color, str):
color_str = color.strip()
hex_val = color_str[1:] if color_str.startswith('#') else color_str
if len(hex_val) == 3:
r, g, b = int(hex_val[0]*2, 16), int(hex_val[1]*2, 16), int(hex_val[2]*2, 16)
a = int(alpha * 255)
elif len(hex_val) >= 6:
r, g, b = int(hex_val[0:2], 16), int(hex_val[2:4], 16), int(hex_val[4:6], 16)
a = int(hex_val[6:8], 16) if len(hex_val) >= 8 else int(alpha * 255)
else:
r, g, b, a = 255, 255, 255, int(alpha * 255)
result = (r, g, b, a)
else:
result = (255, 255, 255, int(alpha * 255))
self._color_cache[cache_key] = result
return result
except:
return (255, 255, 255, int(alpha * 255))
@functools.lru_cache(maxsize=64)
def calculate_text_dimensions(self, font_key: str, text: str, letter_spacing: float,
line_spacing: float, outline_thickness: int,
text_orientation: str) -> Tuple[float, float, List[float], List[float]]:
cache_key = (font_key, text, letter_spacing, line_spacing, outline_thickness, text_orientation)
if cache_key in self._text_dim_cache:
return self._text_dim_cache[cache_key]
temp_img = Image.new("RGB", (1, 1))
draw = ImageDraw.Draw(temp_img)
font_path, size_str = font_key.rsplit(':', 1)
font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default()
lines = text.split('\n') if text_orientation == "horizontal" else [text.replace('\n', '')]
line_heights, line_widths = [], []
for line in lines:
line = line.replace('\t', ' ')
if not line:
line_heights.append(0)
line_widths.append(0)
continue
if text_orientation == "horizontal":
bbox = draw.textbbox((0, 0), line, font=font)
line_width = draw.textlength(line, font=font) + letter_spacing * max(0, len(line) - 1)
line_height = (bbox[3] - bbox[1]) * line_spacing
else:
if line:
char_bbox = draw.textbbox((0, 0), line[0], font=font)
line_width = draw.textlength(line[0], font=font)
line_height = (char_bbox[3] - char_bbox[1] + letter_spacing) * len(line)
else:
line_width = line_height = 0
line_heights.append(line_height + outline_thickness * 2)
line_widths.append(line_width + outline_thickness * 2)
result = (sum(line_heights), max(line_widths) if line_widths else 0, line_heights, line_widths)
self._text_dim_cache[cache_key] = result
return result
def text_fits(self, font_key: str, text: str, max_width: float, max_height: float,
letter_spacing: float, line_spacing: float, outline_thickness: int,
text_orientation: str) -> bool:
total_height, max_line_width, _, _ = self.calculate_text_dimensions(
font_key, text, letter_spacing, line_spacing, outline_thickness, text_orientation
)
return max_line_width <= max_width and total_height <= max_height
def find_optimal_font_size(self, font_path: Optional[str], text: str,
max_width: float, max_height: float,
min_size: int, max_size: int, config: TextRenderConfig,
pbar: Optional[Any] = None) -> Tuple[ImageFont.ImageFont, int]:
font_key = lambda size: f"{font_path or 'default'}:{size}"
if self.text_fits(font_key(max_size), text, max_width, max_height,
config.letter_spacing, config.line_spacing,
config.outline_thickness, config.text_orientation):
return self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default(), max_size
low, high = min_size, max_size
best_size, best_font = min_size, None
while low <= high:
mid = (low + high) // 2
if pbar: pbar.update(1)
try:
font = self.get_cached_font(font_path, mid) if font_path else ImageFont.load_default()
except:
high = mid - 1
continue
if self.text_fits(font_key(mid), text, max_width, max_height,
config.letter_spacing, config.line_spacing,
config.outline_thickness, config.text_orientation):
best_font, best_size = font, mid
low = mid + 1
else:
high = mid - 1
if best_font is None:
best_font = self.get_cached_font(font_path, min_size) if font_path else ImageFont.load_default()
return best_font, best_size
def _prepare_mask_for_opencv(self, mask_tensor: torch.Tensor) -> np.ndarray:
mask_np = mask_tensor.cpu().numpy()
if mask_np.ndim == 3:
if mask_np.shape[0] in [1, 3, 4]:
mask_np = mask_np[0]
elif mask_np.shape[-1] in [1, 3, 4]:
mask_np = mask_np[:, :, 0]
elif mask_np.ndim == 4:
mask_np = mask_np[0]
if mask_np.ndim == 3:
if mask_np.shape[0] in [1, 3, 4]:
mask_np = mask_np[0]
elif mask_np.shape[-1] in [1, 3, 4]:
mask_np = mask_np[:, :, 0]
if mask_np.dtype in [np.float32, np.float64]:
mask_np = (mask_np * 255).astype(np.uint8)
elif mask_np.dtype != np.uint8:
mask_np = mask_np.astype(np.uint8)
if mask_np.ndim != 2:
mask_np = mask_np.squeeze()
_, mask_np = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY)
return mask_np
def calculate_mask_rotation(self, mask_np: np.ndarray) -> float:
mask_hash = hash(mask_np.tobytes())
if mask_hash in self._contour_cache:
return self._contour_cache[mask_hash]
try:
contours, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return 0.0
contour = max(contours, key=cv2.contourArea)
if cv2.contourArea(contour) < 10:
return 0.0
rect = cv2.minAreaRect(contour)
angle = rect[2]
if angle < -45:
angle += 90
elif angle > 45:
angle -= 90
result = -angle
self._contour_cache[mask_hash] = result
return result
except:
return 0.0
def _draw_single_line(self, draw, position, text, font, color, letter_spacing, text_orientation):
x, y = position
if text_orientation == "horizontal":
current_x = x
for char in text:
draw.text((current_x, y), char, font=font, fill=color)
current_x += draw.textlength(char, font=font) + letter_spacing
else:
current_y = y
for char in text:
draw.text((x, current_y), char, font=font, fill=color)
bbox = draw.textbbox((x, current_y), char, font=font)
current_y += (bbox[3] - bbox[1]) + letter_spacing
def render_text_layer(self, text, config, container_size):
text_layer = Image.new("RGBA", container_size, (0, 0, 0, 0))
draw = ImageDraw.Draw(text_layer)
total_h, max_w, line_heights, line_widths = self.calculate_text_dimensions(
f"{config.font.path if hasattr(config.font, 'path') else 'default'}:{config.font.size}",
text, config.letter_spacing, config.line_spacing,
config.outline_thickness, config.text_orientation
)
start_y = (container_size[1] - total_h) // 2
current_y = start_y
lines = text.split('\n') if config.text_orientation == "horizontal" else [text.replace('\n', '')]
for line, line_w, line_h in zip(lines, line_widths, line_heights):
if not line.strip():
current_y += line_h
continue
start_x = (container_size[0] - line_w) // 2
if config.has_outline:
thickness = config.outline_thickness
steps = max(2, thickness // 2)
for dx in range(-thickness, thickness + 1, steps):
for dy in range(-thickness, thickness + 1, steps):
if dx == 0 and dy == 0: continue
self._draw_single_line(draw, (start_x + dx, current_y + dy),
line, config.font, config.outline_color,
config.letter_spacing, config.text_orientation)
self._draw_single_line(draw, (start_x, current_y), line, config.font,
config.text_color, config.letter_spacing, config.text_orientation)
current_y += line_h
if config.has_shadow:
text_layer = self._apply_shadow_effect(text_layer, config)
return text_layer
def _apply_shadow_effect(self, text_layer, config):
alpha = text_layer.getchannel("A")
shadow = Image.new("RGBA", text_layer.size, config.shadow_color)
shadow.putalpha(alpha)
if config.shadow_blur > 0:
blur_radius = int(config.shadow_blur * 20)
if blur_radius > 0:
shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius))
offset_x, offset_y = config.shadow_offset
if offset_x or offset_y:
shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y), Image.BICUBIC, fill=(0, 0, 0, 0))
result = Image.new("RGBA", text_layer.size, (0, 0, 0, 0))
result.paste(shadow, (0, 0), shadow)
result.paste(text_layer, (0, 0), text_layer)
return result
def _process_single(self, image, mask, text, font_name, config, padding, vertical_align, horizontal_align,
rotate_with_mask, min_font_size, max_font_size):
image_np = (image.cpu().numpy() * 255).astype(np.uint8)
image_pil = Image.fromarray(image_np, 'RGB') if image_np.ndim == 3 else Image.fromarray(image_np[0], 'RGB')
mask_np = self._prepare_mask_for_opencv(mask)
mask_pil = Image.fromarray(mask_np, 'L')
bbox = mask_pil.getbbox()
if not bbox:
return image
x1, y1, x2, y2 = bbox
available_w = max(1, x2 - x1 - 2 * padding)
available_h = max(1, y2 - y1 - 2 * padding)
font_path = self.get_font_path(font_name)
pbar = ComfyProgressBar(max_font_size - min_font_size, "Finding font size") if HAS_PBAR else None
config.font, actual_size = self.find_optimal_font_size(font_path, text, available_w, available_h, min_font_size, max_font_size, config, pbar)
angle = self.calculate_mask_rotation(mask_np) if rotate_with_mask else 0
diag = math.sqrt(available_w**2 + available_h**2)
temp_size = int(diag * 1.2) + 50
text_layer = self.render_text_layer(text, config, (temp_size, temp_size))
if rotate_with_mask and abs(angle) > 0.1:
text_layer = text_layer.rotate(angle, center=(temp_size//2, temp_size//2), resample=Image.BICUBIC, expand=False)
rot_bbox = text_layer.getbbox()
if not rot_bbox:
return image
rx1, ry1, rx2, ry2 = rot_bbox
text_w, text_h = rx2 - rx1, ry2 - ry1
if rotate_with_mask:
paste_x = int((x1 + x2 - text_w) / 2)
paste_y = int((y1 + y2 - text_h) / 2)
else:
paste_x = {'left': x1 + padding, 'right': x2 - text_w - padding, 'center': int(x1 + (available_w - text_w) / 2 + padding)}[horizontal_align]
paste_y = {'top': y1 + padding, 'bottom': y2 - text_h - padding, 'center': int(y1 + (available_h - text_h) / 2 + padding)}[vertical_align]
cropped = text_layer.crop(rot_bbox)
image_rgba = image_pil.convert("RGBA")
overlay = Image.new("RGBA", image_rgba.size, (0, 0, 0, 0))
overlay.paste(cropped, (paste_x, paste_y), cropped)
result = Image.alpha_composite(image_rgba, overlay)
result_np = np.array(result.convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(result_np).unsqueeze(0)
def apply_text(self, image: torch.Tensor, mask: torch.Tensor, node_data: str = None, **kwargs) -> Tuple[torch.Tensor]:
try:
if image is None or image.numel() == 0:
raise ValueError("Input image is empty")
if mask is None or mask.numel() == 0:
raise ValueError("Input mask is empty")
data = {}
if node_data:
try:
data = json.loads(node_data)
except:
pass
text = data.get('text', 'Текст')
font_name = data.get('font_name', 'default')
text_color = data.get('text_color', '#FFFFFF')
outline_thickness = data.get('outline_thickness', 0)
outline_color = data.get('outline_color', '#808080')
rotate_with_mask = data.get('rotate_with_mask', True)
text_opacity = data.get('text_opacity', 1.0)
line_spacing = data.get('line_spacing', 1.0)
letter_spacing = data.get('letter_spacing', 0.0)
text_orientation = data.get('text_orientation', 'horizontal')
enable_shadow = data.get('enable_shadow', False)
shadow_color = data.get('shadow_color', '#333333')
shadow_offset_x = data.get('shadow_offset_x', 2)
shadow_offset_y = data.get('shadow_offset_y', 2)
shadow_blur = data.get('shadow_blur', 0.0)
shadow_opacity = data.get('shadow_opacity', 0.8)
config = TextRenderConfig(
font=None,
text_color=self.parse_color(text_color, text_opacity),
outline_color=self.parse_color(outline_color, text_opacity),
outline_thickness=outline_thickness,
letter_spacing=letter_spacing,
line_spacing=line_spacing,
text_orientation=text_orientation,
enable_shadow=enable_shadow,
shadow_color=self.parse_color(shadow_color, shadow_opacity),
shadow_offset=(shadow_offset_x, shadow_offset_y),
shadow_blur=shadow_blur,
text_opacity=int(text_opacity * 100)
)
if image.ndim == 4:
results = []
for i in range(image.shape[0]):
result = self._process_single(image[i], mask[i] if mask.ndim == 4 else mask, text, font_name,
config, 5, 'center', 'center', rotate_with_mask, 8, 500)
results.append(result)
return (torch.cat(results, dim=0),)
else:
result = self._process_single(image, mask, text, font_name, config, 5,
'center', 'center', rotate_with_mask, 8, 500)
return (result,)
except Exception as e:
self.log(f"Error: {type(e).__name__}: {e}", level=0)
import traceback
traceback.print_exc()
return (image,)
NODE_CLASS_MAPPINGS = {"RS_TextOverlay": RS_TextOverlay}
NODE_DISPLAY_NAME_MAPPINGS = {"RS_TextOverlay": "🦊 RS Text Overlay"}
__all__ = ["RS_TextOverlay", "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+20 -20
View File
@@ -18,18 +18,18 @@ PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio"
def load_modules():
try:
current_dir = Path(__file__).parent
logger.debug(f"Сканируем директорию: {current_dir}")
logger.debug(f"Scanning the directory: {current_dir}")
loaded_modules = []
failed_modules = []
for py_file in current_dir.glob('*.py'):
filename = py_file.name
if filename in IGNORED_MODULES:
logger.debug(f"Пропускаем игнорируемый файл: {filename}")
logger.debug(f"Skipping the ignored file: {filename}")
continue
module_name = py_file.stem
logger.debug(f"Попытка загрузки модуля: {module_name}")
logger.debug(f"Module loading attempt: {module_name}")
try:
module = importlib.import_module(f'.{module_name}', package=__name__)
@@ -37,24 +37,24 @@ def load_modules():
loaded_modules.append(module_name)
except ImportError as e:
failed_modules.append((module_name, str(e)))
logger.error(f"Ошибка импорта модуля {module_name}: {e}")
logger.error(f"Module import error {module_name}: {e}")
except Exception as e:
failed_modules.append((module_name, str(e)))
logger.error(f"Неожиданная ошибка в модуле {module_name}: {e}")
logger.error(f"Unexpected error in the module {module_name}: {e}")
missing_required = REQUIRED_MODULES - set(loaded_modules)
if missing_required:
logger.error(f"Обязательные модули не загружены: {missing_required}")
logger.error(f"Required modules are not loaded: {missing_required}")
if not failed_modules:
logger.info(f"Все модули {PACKAGE_DISPLAY_NAME} успешно загружены.")
logger.info(f"✅ All modules 🦊 {PACKAGE_DISPLAY_NAME} loaded successfully")
else:
for mod_name, error in failed_modules:
logger.error(f"Модуль '{mod_name}' не загружен: {error}")
logger.warning(f"Загружено {len(loaded_modules)} модулей, ошибки в {len(failed_modules)} модулях.")
logger.error(f"The module '{mod_name}' is not loaded: {error}")
logger.warning(f"Loaded {len(loaded_modules)} modules, errors in {len(failed_modules)} modules")
except Exception as e:
logger.critical(f"Критическая ошибка при загрузке модулей: {e}")
logger.critical(f"Critical error when loading modules: {e}")
raise
def _merge_module_mappings(module, module_name: str):
@@ -63,39 +63,39 @@ def _merge_module_mappings(module, module_name: str):
new_classes = module.NODE_CLASS_MAPPINGS
duplicates = set(NODE_CLASS_MAPPINGS.keys()) & set(new_classes.keys())
if duplicates:
logger.warning(f"Найдены дубликаты классов в модуле {module_name}: {duplicates}")
logger.warning(f"Duplicate classes found in the module {module_name}: {duplicates}")
NODE_CLASS_MAPPINGS.update(new_classes)
logger.debug(f"Добавлены классы из {module_name}: {list(new_classes.keys())}")
logger.debug(f"Added classes from: {list(new_classes.keys())}")
if hasattr(module, 'NODE_DISPLAY_NAME_MAPPINGS'):
new_names = module.NODE_DISPLAY_NAME_MAPPINGS
duplicates = set(NODE_DISPLAY_NAME_MAPPINGS.keys()) & set(new_names.keys())
if duplicates:
logger.warning(f"Найдены дубликаты имен в модуле {module_name}: {duplicates}")
logger.warning(f"Duplicate names found in the module {module_name}: {duplicates}")
NODE_DISPLAY_NAME_MAPPINGS.update(new_names)
logger.debug(f"Добавлены имена из {module_name}: {list(new_names.keys())}")
logger.debug(f"Added names from {module_name}: {list(new_names.keys())}")
except Exception as e:
logger.error(f"Ошибка при объединении маппингов из модуля {module_name}: {e}")
logger.error(f"Error when combining mappings from the module {module_name}: {e}")
raise
try:
load_modules()
except Exception as e:
logger.critical(f"Фатальная ошибка при инициализации пакета: {e}")
logger.critical(f"Fatal error during package initialization: {e}")
pass
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
if not NODE_CLASS_MAPPINGS:
logger.warning("NODE_CLASS_MAPPINGS пуст - возможно, не найдено модулей с узлами")
logger.warning("NODE_CLASS_MAPPINGS is empty - perhaps no modules with nodes were found")
else:
logger.debug(f"Загружено {len(NODE_CLASS_MAPPINGS)} классов узлов")
logger.debug(f"Uploaded by {len(NODE_CLASS_MAPPINGS)} node classes")
if not NODE_DISPLAY_NAME_MAPPINGS:
logger.debug("NODE_DISPLAY_NAME_MAPPINGS пуст - будут использованы имена классов по умолчанию")
logger.debug("NODE_DISPLAY_NAME_MAPPINGS is empty - the default class names will be used")
else:
logger.debug(f"Загружено {len(NODE_DISPLAY_NAME_MAPPINGS)} отображаемых имен")
logger.debug(f"Loaded {len(NODE_DISPLAY_NAME_MAPPINGS)} display names")
WEB_DIRECTORY = "./web"
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
+5 -3
View File
@@ -10,12 +10,14 @@
},
"comfyui": {
"nodes": [
"Rayko_Image_Text.py"
"Rayko_Save_Image_Pair.py"
"Rayko_Saturation.py"
"Rayko_Intermediate_Spline_Mask.py"
"Rayko_Spline_Mask.py"
"Rayko_Models_Loader.py"
"Rayko_Saturation.py"
"Rayko_Save_Image.py"
"Rayko_Save_Image_Pair.py"
"Rayko_Image_Text.py"
"Rayko_Text_Overlay.py"
]
}
}
+603
View File
@@ -0,0 +1,603 @@
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "RaykoTextOverlay",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "RS_TextOverlay") {
console.log("🦊 [RS_TextOverlay] JS loaded!");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
this.data = {
text: "Текст",
font_name: "Arial.ttf",
text_color: "#FFFFFF",
outline_thickness: 0,
outline_color: "#808080",
rotate_with_mask: true,
text_opacity: 1.0,
line_spacing: 1.0,
letter_spacing: 0.0,
text_orientation: "horizontal",
enable_shadow: false,
shadow_color: "#333333",
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 0.0,
shadow_opacity: 0.8,
font_list: ["default"]
};
this.rowHeight = 28;
this.padding = 10;
this.labelWidth = 120;
this.targetWidth = 350;
this.clickZones = [];
this.widgetYPositions = {};
this.hiddenWidget = this.widgets.find(w => w.name === "node_data");
if (this.hiddenWidget) {
this.hiddenWidget.hidden = true;
this.hiddenWidget.serializeValue = () => {
this.syncData();
return this.hiddenWidget.value;
};
try {
const saved = JSON.parse(this.hiddenWidget.value);
if (saved && typeof saved === 'object') {
this.data = { ...this.data, ...saved };
}
} catch (e) {
console.error("🦊 [RS_TextOverlay] Error loading saved ", e);
}
}
if (this.widgets) {
this.widgets.forEach(w => w.hidden = true);
}
this.setSize([this.targetWidth, 650]);
const self = this;
this.drawSeparator = function(ctx, text, x, y, w, h) {
ctx.fillStyle = "#444";
ctx.fillRect(x, y + 8, w, 1);
ctx.fillStyle = "#888";
ctx.font = "10px sans-serif";
ctx.textAlign = "center";
ctx.fillText(text, x + w/2, y + 22);
};
this.onDrawForeground = function(ctx, visibleRect) {
this.clickZones = [];
const startY = 80;
const rowH = this.rowHeight;
const pad = this.padding;
const labelW = this.labelWidth;
const arrowW = 25;
const inputW = this.size[0] - pad*2 - labelW;
let y = startY;
// === TEXT ===
this.drawLabel(ctx, "TEXT", pad, y, labelW, rowH * 2);
this.drawMultilineField(ctx, this.data.text, pad + labelW, y, inputW, rowH * 2);
this.clickZones.push({ type: "text", x: pad + labelW, y: y, w: inputW, h: rowH * 2 });
y += rowH * 2 + 5;
// === FONT_NAME ===
this.drawLabel(ctx, "FONT", pad, y, labelW, rowH);
this.drawComboField(ctx, this.data.font_name, pad + labelW, y, inputW, rowH);
this.widgetYPositions["font_name"] = y;
this.clickZones.push({ type: "combo", field: "font_name", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
// === TEXT_COLOR ===
this.drawLabel(ctx, "TEXT COLOR", pad, y, labelW, rowH);
this.drawColorField(ctx, this.data.text_color, pad + labelW, y, inputW, rowH);
this.widgetYPositions["text_color"] = y;
this.clickZones.push({ type: "color", field: "text_color", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
// === OUTLINE_THICKNESS ===
this.drawLabel(ctx, "OUTLINE THICK", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.outline_thickness, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["outline_thickness"] = y;
this.clickZones.push({ type: "number", field: "outline_thickness", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 50 });
this.clickZones.push({ type: "arrow_left", field: "outline_thickness", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 50, step: 1 });
this.clickZones.push({ type: "arrow_right", field: "outline_thickness", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 50, step: 1 });
y += rowH + 5;
// === OUTLINE_COLOR ===
this.drawLabel(ctx, "OUTLINE COLOR", pad, y, labelW, rowH);
this.drawColorField(ctx, this.data.outline_color, pad + labelW, y, inputW, rowH);
this.widgetYPositions["outline_color"] = y;
this.clickZones.push({ type: "color", field: "outline_color", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
// === FORMATTING TEXT ===
this.drawSeparator(ctx, "FORMATTING TEXT", pad, y, inputW + labelW, rowH);
y += rowH + 5;
// === ROTATE_WITH_MASK ===
this.drawLabel(ctx, "ROTATE WITH MASK", pad, y, labelW, rowH);
this.drawBooleanField(ctx, this.data.rotate_with_mask, pad + labelW, y, inputW, rowH);
this.widgetYPositions["rotate_with_mask"] = y;
this.clickZones.push({ type: "boolean", field: "rotate_with_mask", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
// === TEXT_OPACITY ===
this.drawLabel(ctx, "TEXT OPACITY", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.text_opacity, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["text_opacity"] = y;
this.clickZones.push({ type: "number", field: "text_opacity", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 1, float: true });
this.clickZones.push({ type: "arrow_left", field: "text_opacity", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true });
this.clickZones.push({ type: "arrow_right", field: "text_opacity", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true });
y += rowH + 5;
// === LINE_SPACING ===
this.drawLabel(ctx, "LINE SPACING", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.line_spacing, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["line_spacing"] = y;
this.clickZones.push({ type: "number", field: "line_spacing", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0.5, max: 3, float: true });
this.clickZones.push({ type: "arrow_left", field: "line_spacing", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0.5, max: 3, step: 0.1, float: true });
this.clickZones.push({ type: "arrow_right", field: "line_spacing", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0.5, max: 3, step: 0.1, float: true });
y += rowH + 5;
// === LETTER_SPACING ===
this.drawLabel(ctx, "LETTER SPACING", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.letter_spacing, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["letter_spacing"] = y;
this.clickZones.push({ type: "number", field: "letter_spacing", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -20, max: 100, float: true });
this.clickZones.push({ type: "arrow_left", field: "letter_spacing", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -20, max: 100, step: 0.5, float: true });
this.clickZones.push({ type: "arrow_right", field: "letter_spacing", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -20, max: 100, step: 0.5, float: true });
y += rowH + 5;
// === TEXT_ORIENTATION ===
this.drawLabel(ctx, "ORIENTATION", pad, y, labelW, rowH);
this.drawComboField(ctx, this.data.text_orientation, pad + labelW, y, inputW, rowH);
this.widgetYPositions["text_orientation"] = y;
this.clickZones.push({ type: "combo", field: "text_orientation", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
// === SETTING SHADOW ===
this.drawSeparator(ctx, "SETTING SHADOW", pad, y, inputW + labelW, rowH);
y += rowH + 5;
// === ENABLE_SHADOW ===
this.drawLabel(ctx, "ENABLE SHADOW", pad, y, labelW, rowH);
this.drawBooleanField(ctx, this.data.enable_shadow, pad + labelW, y, inputW, rowH);
this.widgetYPositions["enable_shadow"] = y;
this.clickZones.push({ type: "boolean", field: "enable_shadow", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
// === SHADOW_COLOR ===
this.drawLabel(ctx, "SHADOW COLOR", pad, y, labelW, rowH);
this.drawColorField(ctx, this.data.shadow_color, pad + labelW, y, inputW, rowH);
this.widgetYPositions["shadow_color"] = y;
this.clickZones.push({ type: "color", field: "shadow_color", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
// === SHADOW_OFFSET_X ===
this.drawLabel(ctx, "SHADOW OFFSET X", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.shadow_offset_x, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["shadow_offset_x"] = y;
this.clickZones.push({ type: "number", field: "shadow_offset_x", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -50, max: 50 });
this.clickZones.push({ type: "arrow_left", field: "shadow_offset_x", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 });
this.clickZones.push({ type: "arrow_right", field: "shadow_offset_x", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 });
y += rowH + 5;
// === SHADOW_OFFSET_Y ===
this.drawLabel(ctx, "SHADOW OFFSET Y", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.shadow_offset_y, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["shadow_offset_y"] = y;
this.clickZones.push({ type: "number", field: "shadow_offset_y", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -50, max: 50 });
this.clickZones.push({ type: "arrow_left", field: "shadow_offset_y", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 });
this.clickZones.push({ type: "arrow_right", field: "shadow_offset_y", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 });
y += rowH + 5;
// === SHADOW_BLUR ===
this.drawLabel(ctx, "SHADOW BLUR", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.shadow_blur, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["shadow_blur"] = y;
this.clickZones.push({ type: "number", field: "shadow_blur", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 1, float: true });
this.clickZones.push({ type: "arrow_left", field: "shadow_blur", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true });
this.clickZones.push({ type: "arrow_right", field: "shadow_blur", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true });
y += rowH + 5;
// === SHADOW_OPACITY ===
this.drawLabel(ctx, "SHADOW OPACITY", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.shadow_opacity, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["shadow_opacity"] = y;
this.clickZones.push({ type: "number", field: "shadow_opacity", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 1, float: true });
this.clickZones.push({ type: "arrow_left", field: "shadow_opacity", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true });
this.clickZones.push({ type: "arrow_right", field: "shadow_opacity", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true });
y += rowH + 15;
const totalH = y + 10;
if (this.size[1] < totalH) {
this.setSize([this.targetWidth, totalH]);
}
};
this.onMouseDown = function(e, pos, canvas) {
if (!this.clickZones.length) return false;
for (const zone of this.clickZones) {
const inX = pos[0] >= zone.x && pos[0] <= zone.x + zone.w;
const inY = pos[1] >= zone.y && pos[1] <= zone.y + zone.h;
if (inX && inY) {
if (zone.type === "text") {
self.showTextInput();
return true;
}
if (zone.type === "combo") {
console.log("🔽 Combo clicked:", zone.field);
self.showComboSelector(zone.field);
return true;
}
if (zone.type === "color") {
console.log("🎨 Color clicked:", zone.field);
self.openColorPicker(zone.field);
return true;
}
if (zone.type === "boolean") {
self.data[zone.field] = !self.data[zone.field];
self.updateUI();
return true;
}
if (zone.type === "number") {
const current = self.data[zone.field];
const newVal = prompt(`${zone.field}:`, current);
if (newVal !== null) {
let parsed = zone.float ? parseFloat(newVal) : parseInt(newVal);
if (!isNaN(parsed)) {
parsed = Math.max(zone.min, Math.min(zone.max, parsed));
self.data[zone.field] = zone.float ? Math.round(parsed * 100) / 100 : parsed;
self.updateUI();
}
}
return true;
}
if (zone.type === "arrow_left") {
let current = self.data[zone.field];
current = zone.float ? current - zone.step : current - zone.step;
current = zone.float ? Math.round(current * 100) / 100 : current;
self.data[zone.field] = Math.max(zone.min, current);
self.updateUI();
return true;
}
if (zone.type === "arrow_right") {
let current = self.data[zone.field];
current = zone.float ? current + zone.step : current + zone.step;
current = zone.float ? Math.round(current * 100) / 100 : current;
self.data[zone.field] = Math.min(zone.max, current);
self.updateUI();
return true;
}
}
}
return false;
};
this.drawLabel = function(ctx, text, x, y, w, h) {
ctx.fillStyle = "#aaa";
ctx.font = "11px sans-serif";
ctx.textAlign = "left";
ctx.fillText(text, x, y + h/2 + 4);
};
this.drawMultilineField = function(ctx, value, x, y, w, h) {
ctx.fillStyle = "#222";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "#444";
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = "#fff";
ctx.font = "11px sans-serif";
ctx.textAlign = "left";
const display = value.length > 30 ? value.substring(0, 27) + "..." : value;
ctx.fillText(display.replace('\n', '\\n'), x + 5, y + h/2 + 4);
};
this.drawComboField = function(ctx, value, x, y, w, h) {
ctx.fillStyle = "#222";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "#444";
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = "#fff";
ctx.font = "11px sans-serif";
ctx.textAlign = "center";
ctx.fillText(value, x + w/2, y + h/2 + 4);
ctx.fillStyle = "#666";
ctx.beginPath();
ctx.moveTo(x + w - 12, y + h/2 - 3);
ctx.lineTo(x + w - 6, y + h/2 - 3);
ctx.lineTo(x + w - 9, y + h/2 + 3);
ctx.fill();
};
this.drawColorField = function(ctx, value, x, y, w, h) {
const hexW = w - 25;
ctx.fillStyle = "#222";
ctx.fillRect(x, y, hexW, h);
ctx.strokeStyle = "#444";
ctx.strokeRect(x, y, hexW, h);
ctx.fillStyle = "#fff";
ctx.font = "11px sans-serif";
ctx.textAlign = "center";
ctx.fillText(value, x + hexW/2, y + h/2 + 4);
const colorBoxX = x + hexW + 5;
const colorBoxY = y + 0;
const colorBoxH = h + 0;
ctx.fillStyle = value;
ctx.fillRect(colorBoxX, colorBoxY, 20, colorBoxH);
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1.5;
ctx.strokeRect(colorBoxX, colorBoxY, 20, colorBoxH);
};
this.drawNumberFieldWithArrows = function(ctx, value, x, y, w, h, arrowW) {
ctx.fillStyle = "#222";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "#444";
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = "#4CAF50";
ctx.beginPath();
ctx.moveTo(x + 8, y + h/2);
ctx.lineTo(x + 16, y + h/2 - 6);
ctx.lineTo(x + 16, y + h/2 + 6);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#4CAF50";
ctx.beginPath();
ctx.moveTo(x + w - 8, y + h/2);
ctx.lineTo(x + w - 16, y + h/2 - 6);
ctx.lineTo(x + w - 16, y + h/2 + 6);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = "11px sans-serif";
ctx.textAlign = "center";
const displayValue = typeof value === 'number' && value % 1 !== 0 ? value.toFixed(2) : value;
ctx.fillText(displayValue, x + w/2, y + h/2 + 4);
};
this.drawBooleanField = function(ctx, value, x, y, w, h) {
ctx.fillStyle = "#222";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "#444";
ctx.strokeRect(x, y, w, h);
const circleX = x + w - 35;
const circleY = y + h/2;
ctx.fillStyle = value ? "#4CAF50" : "#555";
ctx.beginPath();
ctx.arc(circleX, circleY, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = "11px sans-serif";
ctx.textAlign = "left";
ctx.fillText(value ? "ON" : "OFF", x + 8, y + h/2 + 4);
};
this.showComboSelector = function(fieldName) {
const options = {
font_name: self.data.font_list || ["default"],
vertical_align: ["top", "center", "bottom"],
horizontal_align: ["left", "center", "right"],
text_orientation: ["horizontal", "vertical"]
};
const list = options[fieldName] || [];
if (!list.length) return;
const menu = document.createElement("div");
menu.style.cssText = `
position: fixed;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
max-height: 300px;
overflow-y: auto;
z-index: 10001;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
min-width: 150px;
`;
list.forEach(opt => {
const item = document.createElement("div");
item.textContent = opt;
item.style.cssText = `
padding: 10px 15px;
cursor: pointer;
color: #ddd;
font-size: 12px;
border-bottom: 1px solid #333;
`;
item.onmouseover = () => item.style.background = "#333";
item.onmouseout = () => item.style.background = "#1a1a1a";
item.onclick = (e) => {
e.stopPropagation();
e.preventDefault();
console.log("✅ Combo selected:", opt);
self.data[fieldName] = opt;
self.updateUI();
menu.remove();
};
menu.appendChild(item);
});
const widgetY = self.widgetYPositions[fieldName] || 200;
const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect() ||
document.querySelector("canvas")?.getBoundingClientRect();
if (canvasRect && self.pos) {
const menuX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth;
const menuY = canvasRect.top + self.pos[1] + widgetY + self.rowHeight;
menu.style.left = menuX + "px";
menu.style.top = menuY + "px";
} else {
menu.style.left = "250px";
menu.style.top = "200px";
}
document.body.appendChild(menu);
setTimeout(() => {
const closeHandler = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener("mousedown", closeHandler);
}
};
document.addEventListener("mousedown", closeHandler);
}, 100);
};
this.showTextInput = function() {
const currentValue = self.data.text || '';
const popup = document.createElement('div');
popup.style.cssText = `
position: fixed;
z-index: 10002;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
padding: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
`;
const input = document.createElement('textarea');
input.value = currentValue;
input.style.cssText = `
width: 300px;
height: 150px;
background: #222;
color: #fff;
border: 1px solid #444;
border-radius: 4px;
padding: 10px;
font-size: 12px;
resize: none;
display: block;
margin-bottom: 10px;
`;
const saveBtn = document.createElement('button');
saveBtn.textContent = '✅';
saveBtn.style.cssText = `
background: #4CAF50;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
float: right;
`;
saveBtn.onmouseover = () => saveBtn.style.background = "#45a049";
saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50";
popup.appendChild(input);
popup.appendChild(saveBtn);
const widgetY = self.widgetYPositions["text"] || 80;
const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect();
if (canvasRect && self.pos) {
const inputX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth;
const inputY = canvasRect.top + self.pos[1] + widgetY + (self.rowHeight * 2);
popup.style.left = inputX + 'px';
popup.style.top = inputY + 'px';
}
document.body.appendChild(popup);
input.focus();
saveBtn.onclick = (e) => {
e.stopPropagation();
e.preventDefault();
self.data.text = input.value;
self.updateUI();
popup.remove();
};
};
this.openColorPicker = function(fieldName) {
const currentColor = self.data[fieldName] || '#FFFFFF';
console.log("🎨 Opening color picker:", fieldName, currentColor);
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = currentColor;
colorInput.style.display = 'none';
document.body.appendChild(colorInput);
colorInput.addEventListener('change', (e) => {
e.stopPropagation();
e.preventDefault();
const newColor = e.target.value.toUpperCase();
self.data[fieldName] = newColor;
console.log("🎨 Color changed:", newColor);
self.updateUI();
}, { once: false });
const widgetY = self.widgetYPositions[fieldName] || 200;
const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect() ||
document.querySelector("canvas")?.getBoundingClientRect();
if (canvasRect && self.pos) {
const pickerX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth;
const pickerY = canvasRect.top + self.pos[1] + widgetY;
colorInput.style.position = 'fixed';
colorInput.style.left = pickerX + 'px';
colorInput.style.top = pickerY + 'px';
colorInput.style.zIndex = '10002';
colorInput.style.width = '1px';
colorInput.style.height = '1px';
colorInput.style.opacity = '0';
console.log("📍 Picker positioned at:", pickerX, pickerY);
requestAnimationFrame(() => {
if (colorInput.showPicker) {
colorInput.showPicker();
} else {
colorInput.click();
}
});
} else {
colorInput.style.position = 'fixed';
colorInput.style.left = (window.innerWidth / 2) + 'px';
colorInput.style.top = (window.innerHeight / 2) + 'px';
colorInput.style.zIndex = '10002';
requestAnimationFrame(() => {
if (colorInput.showPicker) {
colorInput.showPicker();
} else {
colorInput.click();
}
});
}
const clickOutsideHandler = (e) => {
if (e.target !== colorInput) {
colorInput.remove();
document.removeEventListener("mousedown", clickOutsideHandler);
}
};
setTimeout(() => {
document.addEventListener("mousedown", clickOutsideHandler);
}, 200);
};
this.syncData = function() {
if (this.hiddenWidget) {
this.hiddenWidget.value = JSON.stringify(self.data, null, 2)
.replace(/\\u([0-9a-fA-F]{4})/g, function(match, p1) {
return String.fromCharCode(parseInt(p1, 16));
});
}
};
this.updateUI = function() {
self.syncData();
if (self.graph) self.graph.setDirtyCanvas(true, true);
};
const onSerialize = this.onSerialize;
this.onSerialize = function(o) {
self.syncData();
return onSerialize ? onSerialize.apply(this, arguments) : undefined;
};
const onExecute = this.onExecute;
this.onExecute = function() {
self.syncData();
return onExecute ? onExecute.apply(this, arguments) : undefined;
};
return result;
};
}
}
});
console.log("🦊 [RS_TextOverlay] Extension initialized");