diff --git a/Example/RS Text Overlay Pro.json b/Example/RS Text Overlay Pro.json
new file mode 100644
index 0000000..1f1643d
--- /dev/null
+++ b/Example/RS Text Overlay Pro.json
@@ -0,0 +1 @@
+{"id":"ec300d91-5775-45b4-952d-d1c8a58a5691","revision":0,"last_node_id":106,"last_link_id":183,"nodes":[{"id":100,"type":"RS_OverlayPro","pos":[2540,130],"size":[500,730],"flags":{"pinned":true},"order":4,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":169}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[182]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"2bbe4adeb4f9234034c70e3fc9ea0f4154e0eecc","Node name for S&R":"RS_OverlayPro","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":3,"type":"PreviewImage","pos":[3060,130],"size":[570,730],"flags":{"pinned":true},"order":5,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":182}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":[183]}],"properties":{"cnr_id":"comfy-core","ver":"0.25.0","Node name for S&R":"PreviewImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":105,"type":"SaveImage","pos":[3650,130],"size":[590,730],"flags":{"pinned":true},"order":6,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":183},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.26.0","Node name for S&R":"SaveImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["ComfyUI"]},{"id":103,"type":"RS_OverlayPro","pos":[1500,130],"size":[500,730],"flags":{"pinned":true},"order":2,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":178}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[179]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"2bbe4adeb4f9234034c70e3fc9ea0f4154e0eecc","Node name for S&R":"RS_OverlayPro","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":99,"type":"RS_OverlayPro","pos":[2020,130],"size":[500,730],"flags":{"pinned":true},"order":3,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":179}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[169]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"2bbe4adeb4f9234034c70e3fc9ea0f4154e0eecc","Node name for S&R":"RS_OverlayPro","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":2,"type":"LoadImage","pos":[1070,130],"size":[410,570],"flags":{"pinned":true},"order":0,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[178]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.25.0","Node name for S&R":"LoadImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["ComfyUI_00005_ (2).png","image"]},{"id":106,"type":"Fast Groups Bypasser (rgthree)","pos":[1070,760],"size":[410,100],"flags":{"pinned":true},"order":1,"mode":0,"inputs":[],"outputs":[{"name":"OPT_CONNECTION","type":"*","links":null}],"properties":{"matchColors":"","matchTitle":"","showNav":true,"showAllGraphs":true,"sort":"position","customSortAlphabet":"","toggleRestriction":"default","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"color":"#322","bgcolor":"#533"}],"links":[[169,99,0,100,0,"IMAGE"],[178,2,0,103,0,"IMAGE"],[179,103,0,99,0,"IMAGE"],[182,100,0,3,0,"IMAGE"],[183,3,0,105,0,"IMAGE"]],"groups":[{"id":1,"title":"Overlay 2","bounding":[2010,60,520,810],"color":"#3f789e","flags":{"pinned":true}},{"id":2,"title":"Overlay 3","bounding":[2530,60,520,810],"color":"#3f789e","flags":{"pinned":true}}],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":0.6934334949441373,"offset":[-463.92423240571884,203.39108182291372]}},"version":0.4}
\ No newline at end of file
diff --git a/Rayko_Text_Overlay.py b/Rayko_Text_Overlay.py
deleted file mode 100644
index 2b19c3e..0000000
--- a/Rayko_Text_Overlay.py
+++ /dev/null
@@ -1,864 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-# Copyright 2025-2026 Raykosan (RaykoStudio)
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-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
-import time
-import re
-import unicodedata
-from contextlib import contextmanager
-from collections import OrderedDict
-
-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
- def update_absolute(self, n: int): pass
- return DummyPBar()
-
-try:
- import folder_paths
- HAS_FOLDER_PATHS = True
-except ImportError:
- HAS_FOLDER_PATHS = False
-
-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
- enable_shadow: bool
- shadow_color: Tuple[int, int, int, int]
- shadow_offset: Tuple[int, int]
- shadow_blur: float
- shadow_opacity: float
- enable_glow: bool = False
- glow_color: Tuple[int, int, int, int] = (255, 255, 255, 255)
- glow_size: int = 150
- glow_spread: int = 250
- glow_brightness: float = 1.0
- text_opacity: int = 100
- use_supersampling: bool = False
- use_edge_smoothing: bool = True
- supersampling_factor: int = 2
-
- @property
- def has_outline(self) -> bool:
- return self.outline_thickness > 0
-
- @property
- def has_shadow(self) -> bool:
- return self.enable_shadow
-
- @property
- def has_glow(self) -> bool:
- return self.enable_glow
-
-class LRUCache:
- def __init__(self, max_size: int = 100):
- self.cache = OrderedDict()
- self.max_size = max_size
-
- def get(self, key):
- if key not in self.cache:
- return None
- self.cache.move_to_end(key)
- return self.cache[key]
-
- def put(self, key, value):
- if key in self.cache:
- self.cache.move_to_end(key)
- self.cache[key] = value
- if len(self.cache) > self.max_size:
- self.cache.popitem(last=False)
-
- def clear(self):
- self.cache.clear()
-
- def __len__(self):
- return len(self.cache)
-
-class HighQualityTextRenderer:
- @staticmethod
- def render_with_supersampling(text: str, config: TextRenderConfig,
- container_size: Tuple[int, int],
- render_func, scale_factor: int = 2) -> Image.Image:
- scaled_size = (container_size[0] * scale_factor, container_size[1] * scale_factor)
-
- temp_config = TextRenderConfig(
- font=None,
- text_color=config.text_color,
- outline_color=config.outline_color,
- outline_thickness=config.outline_thickness * scale_factor,
- letter_spacing=config.letter_spacing * scale_factor,
- line_spacing=config.line_spacing,
- enable_shadow=config.enable_shadow,
- shadow_color=config.shadow_color,
- shadow_offset=(config.shadow_offset[0] * scale_factor,
- config.shadow_offset[1] * scale_factor),
- shadow_blur=config.shadow_blur * scale_factor,
- shadow_opacity=config.shadow_opacity,
- text_opacity=config.text_opacity,
- use_supersampling=False,
- use_edge_smoothing=False,
- enable_glow=config.enable_glow,
- glow_color=config.glow_color,
- glow_size=config.glow_size * scale_factor,
- glow_spread=config.glow_spread * scale_factor,
- glow_brightness=config.glow_brightness
- )
-
- try:
- scaled_font_size = int(config.font.size * scale_factor)
- temp_config.font = ImageFont.truetype(
- config.font.path if hasattr(config.font, 'path') else "default",
- scaled_font_size
- )
- except:
- temp_config.font = config.font
-
- temp_layer = render_func(text, temp_config, scaled_size)
- result = temp_layer.resize(container_size, Image.Resampling.LANCZOS)
- return result
-
- @staticmethod
- def apply_edge_smoothing(text_layer: Image.Image) -> Image.Image:
- arr = np.array(text_layer)
-
- if arr.ndim == 3 and arr.shape[2] == 4:
- alpha = arr[:, :, 3]
- rgb = arr[:, :, :3]
- edges = cv2.Canny(alpha, 50, 150)
-
- if np.any(edges):
- smoothed_rgb = cv2.bilateralFilter(rgb, 5, 50, 50)
- alpha_smoothed = cv2.GaussianBlur(alpha, (3, 3), 0.5)
- edge_mask = edges > 0
- edge_mask_3d = np.stack([edge_mask] * 4, axis=2)
- result_arr = np.dstack([smoothed_rgb, alpha_smoothed])
- original_arr = np.dstack([rgb, alpha])
- result = np.where(edge_mask_3d, result_arr, original_arr)
- return Image.fromarray(result.astype(np.uint8), 'RGBA')
-
- return text_layer
-
-class RS_TextOverlay:
- WEB_DIRECTORY = "web"
- _font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {}
- _text_dim_cache: Dict[Tuple[str, str, float, float, int], Tuple[float, float, List[float], List[float]]] = {}
- _contour_cache: Dict[int, float] = {}
- _render_cache: LRUCache = None
-
- _hex_color_pattern = re.compile(r'^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})?$')
- _hex_short_pattern = re.compile(r'^#?([0-9A-Fa-f])([0-9A-Fa-f])([0-9A-Fa-f])$')
-
- @classmethod
- def INPUT_TYPES(cls):
- current_dir = os.path.dirname(os.path.abspath(__file__))
- fonts_dir = os.path.join(current_dir, "fonts")
- font_list = []
-
- if os.path.isdir(fonts_dir):
- for f in sorted(os.listdir(fonts_dir)):
- if f.lower().endswith(('.ttf', '.otf', '.ttc')):
- font_list.append(f)
-
- if not font_list:
- font_list = ["Arial.ttf"]
-
- return {
- "required": {
- "image": ("IMAGE",),
- "mask": ("MASK",),
- "node_data": ("STRING", {
- "default": json.dumps({
- "text": "Текст",
- "font_name": font_list[0],
- "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,
- "enable_shadow": False,
- "shadow_color": "#333333",
- "shadow_offset_x": 10,
- "shadow_offset_y": 10,
- "shadow_blur": 0.2,
- "shadow_opacity": 0.8,
- "enable_glow": False,
- "glow_color": "#FFFFFF",
- "glow_size": 150,
- "glow_spread": 250,
- "glow_brightness": 1.0,
- "font_list": font_list,
- "use_supersampling": False,
- "use_edge_smoothing": True,
- "supersampling_factor": 2
- }),
- "hidden": True
- }),
- },
- "optional": {}
- }
-
- RETURN_TYPES = ("IMAGE",)
- FUNCTION = "apply_text"
- CATEGORY = "🦊 RaykoStudio"
- DESCRIPTION = "Node allows you to overlay text on images using masks"
-
- def __init__(self):
- self.debug = False
- self.debug_log = []
- self.quality_renderer = HighQualityTextRenderer()
-
- if self._render_cache is None:
- self.__class__._render_cache = LRUCache(max_size=100)
-
- self.performance_metrics = {
- 'render_time': [],
- 'font_find_time': [],
- 'mask_process_time': [],
- 'total_time': []
- }
-
- self._mask_cache = LRUCache(max_size=50)
- self._instance_color_cache: Dict[str, Tuple[int, int, int, int]] = {}
-
- def log(self, message: str, level: int = 1):
- if self.debug or level <= 0:
- timestamp = time.strftime("%H:%M:%S")
- prefix = {0: "❌", 1: "ℹ️", 2: "🔍"}.get(level, "•")
- print(f"[{timestamp}] {prefix} RS_TextOverlay: {message}")
-
- if self.debug:
- self.debug_log.append(f"[{timestamp}] {prefix} {message}")
- if len(self.debug_log) > 1000:
- self.debug_log = self.debug_log[-500:]
-
- @contextmanager
- def measure_time(self, metric_name: str):
- start = time.perf_counter()
- yield
- elapsed = time.perf_counter() - start
-
- if metric_name in self.performance_metrics:
- self.performance_metrics[metric_name].append(elapsed)
- if len(self.performance_metrics[metric_name]) > 100:
- self.performance_metrics[metric_name] = self.performance_metrics[metric_name][-50:]
-
- def _validate_inputs(self, image: torch.Tensor, mask: torch.Tensor) -> bool:
- if image is None or mask is None:
- self.log("Image or mask is None", level=0)
- return False
- if image.numel() == 0 or mask.numel() == 0:
- self.log("Empty tensor", level=0)
- return False
- if image.ndim not in [3, 4]:
- self.log(f"Invalid image dimensions: {image.ndim}", level=0)
- return False
- if mask.ndim not in [2, 3, 4]:
- self.log(f"Invalid mask dimensions: {mask.ndim}", level=0)
- return False
- if torch.isnan(image).any() or torch.isinf(image).any():
- self.log("Image contains NaN or Inf values", level=0)
- return False
- return True
-
- def _validate_text(self, text: str) -> str:
- if not isinstance(text, str):
- return "Текст"
- if not text.strip():
- return "Текст"
- text = unicodedata.normalize('NFKC', text)
- text = ''.join(char for char in text if char.isprintable() or char == '\n')
- max_length = 1000
- if len(text) > max_length:
- text = text[:max_length] + "..."
- self.log(f"Text truncated to {max_length} characters", level=1)
- return text
-
- def _parse_and_validate_node_data(self, node_data: str) -> dict:
- current_dir = os.path.dirname(os.path.abspath(__file__))
- fonts_dir = os.path.join(current_dir, "fonts")
- font_list = []
-
- if os.path.isdir(fonts_dir):
- for f in sorted(os.listdir(fonts_dir)):
- if f.lower().endswith(('.ttf', '.otf', '.ttc')):
- font_list.append(f)
-
- if not font_list:
- font_list = ["Arial.ttf"]
-
- defaults = {
- 'text': 'Текст',
- 'font_name': font_list[0],
- '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,
- 'enable_shadow': False,
- 'shadow_color': '#333333',
- 'shadow_offset_x': 10,
- 'shadow_offset_y': 10,
- 'shadow_blur': 0.2,
- 'shadow_opacity': 0.8,
- 'enable_glow': False,
- 'glow_color': '#FFFFFF',
- 'glow_size': 150,
- 'glow_spread': 250,
- 'glow_brightness': 1.0,
- 'font_list': font_list,
- 'use_supersampling': False,
- 'use_edge_smoothing': True,
- 'supersampling_factor': 2,
- 'min_font_size': 8,
- 'max_font_size': 500,
- 'padding': 5
- }
-
- if not node_data:
- return defaults
-
- try:
- data = json.loads(node_data)
-
- data['text'] = self._validate_text(data.get('text', defaults['text']))
- data['outline_thickness'] = max(0, min(50, data.get('outline_thickness', 0)))
- data['text_opacity'] = max(0.0, min(1.0, data.get('text_opacity', 1.0)))
- data['line_spacing'] = max(0.5, min(3.0, data.get('line_spacing', 1.0)))
- data['letter_spacing'] = max(-20.0, min(100.0, data.get('letter_spacing', 0.0)))
- data['shadow_blur'] = max(0.0, min(1.0, data.get('shadow_blur', 0.2)))
- data['shadow_opacity'] = max(0.0, min(1.0, data.get('shadow_opacity', 0.8)))
- data['shadow_offset_x'] = max(-500, min(500, data.get('shadow_offset_x', 10)))
- data['shadow_offset_y'] = max(-500, min(500, data.get('shadow_offset_y', 10)))
- data['glow_size'] = max(0, min(500, int(data.get('glow_size', 150))))
- data['glow_spread'] = max(0, min(500, int(data.get('glow_spread', 250))))
- data['glow_brightness'] = max(0.0, min(1.0, data.get('glow_brightness', 1.0)))
- data['supersampling_factor'] = max(2, min(4, data.get('supersampling_factor', 2)))
- data['min_font_size'] = max(1, min(100, data.get('min_font_size', 8)))
- data['max_font_size'] = max(data['min_font_size'], min(1000, data.get('max_font_size', 500)))
- data['font_list'] = font_list
-
- return {**defaults, **data}
-
- except json.JSONDecodeError as e:
- self.log(f"Error parsing node_data: {e}", level=0)
- return defaults
- except Exception as e:
- self.log(f"Error validating node_data: {e}", level=0)
- return defaults
-
- def _is_simple_rectangle(self, mask: np.ndarray) -> bool:
- try:
- contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
- if not contours:
- return False
- contour = max(contours, key=cv2.contourArea)
- epsilon = 0.02 * cv2.arcLength(contour, True)
- approx = cv2.approxPolyDP(contour, epsilon, True)
- if len(approx) == 4:
- area_contour = cv2.contourArea(contour)
- area_approx = cv2.contourArea(approx)
- return area_approx > 0.9 * area_contour
- return False
- except Exception as e:
- self.log(f"Error checking rectangle: {e}", level=2)
- return False
-
- def get_font_path(self, font_name: str) -> Optional[str]:
- 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):
- return local_path
- 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, layout_engine=ImageFont.Layout.BASIC)
- font.path = font_path
- font.size = 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))
-
- try:
- if isinstance(color, str):
- color_str = color.strip()
- match = self._hex_color_pattern.match(color_str)
- if match:
- r, g, b = int(match.group(1), 16), int(match.group(2), 16), int(match.group(3), 16)
- a = int(match.group(4), 16) if match.group(4) else int(alpha * 255)
- return (r, g, b, a)
- else:
- match_short = self._hex_short_pattern.match(color_str)
- if match_short:
- r, g, b = int(match_short.group(1)*2, 16), int(match_short.group(2)*2, 16), int(match_short.group(3)*2, 16)
- a = int(alpha * 255)
- return (r, g, b, a)
- else:
- return (255, 255, 255, int(alpha * 255))
- else:
- return (255, 255, 255, int(alpha * 255))
- except Exception as e:
- self.log(f"Error parsing color {color}: {e}", level=2)
- 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) -> Tuple[float, float, List[float], List[float]]:
- cache_key = (font_key, text, letter_spacing, line_spacing, outline_thickness)
- 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) if ':' in font_key else (font_key, '12')
- try:
- font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default()
- except:
- font = ImageFont.load_default()
- lines = text.split('\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
- 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
- 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) -> bool:
- total_height, max_line_width, _, _ = self.calculate_text_dimensions(
- font_key, text, letter_spacing, line_spacing, outline_thickness
- )
- 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]:
- def font_key(size):
- return 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):
- try:
- font = self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default()
- if font_path and hasattr(font, 'path'):
- font.path = font_path
- font.size = max_size
- return font, max_size
- except:
- pass
- low, high = min_size, max_size
- best_size, best_font = min_size, None
- iterations = 0
- max_iterations = 50
- while low <= high and iterations < max_iterations:
- mid = (low + high) // 2
- iterations += 1
- 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):
- best_font, best_size = font, mid
- low = mid + 1
- else:
- high = mid - 1
- if best_font is None:
- try:
- best_font = self.get_cached_font(font_path, min_size) if font_path else ImageFont.load_default()
- if font_path and hasattr(best_font, 'path'):
- best_font.path = font_path
- best_font.size = min_size
- except:
- best_font = ImageFont.load_default()
- return best_font, best_size
-
- def _prepare_mask_for_opencv(self, mask_tensor: torch.Tensor) -> np.ndarray:
- mask_hash = hash(mask_tensor.cpu().numpy().tobytes())
- cached = self._mask_cache.get(mask_hash)
- if cached is not None:
- return cached
- 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)
- self._mask_cache.put(mask_hash, mask_np)
- 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)
- area = cv2.contourArea(contour)
- if area < 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 Exception as e:
- self.log(f"Error calculating mask rotation: {e}", level=2)
- return 0.0
-
- def _draw_single_line(self, draw, position, text, font, color, letter_spacing):
- x, y = position
- 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
-
- def _render_text_core(self, text: str, config: TextRenderConfig,
- container_size: Tuple[int, int]) -> Image.Image:
- 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
- )
- start_y = (container_size[1] - total_h) // 2
- current_y = start_y
- lines = text.split('\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:
- draw.text((start_x, current_y), line, font=config.font,
- fill=config.text_color, stroke_width=config.outline_thickness,
- stroke_fill=config.outline_color)
- else:
- self._draw_single_line(draw, (start_x, current_y), line, config.font,
- config.text_color, config.letter_spacing)
- current_y += line_h
- return text_layer
-
- def _remap_value(self, ui_value: float, min_actual: float = 0.7, max_actual: float = 1.0) -> float:
- return min_actual + (ui_value * (max_actual - min_actual))
-
- def _apply_glow_effect(self, text_layer: Image.Image, config: TextRenderConfig) -> Image.Image:
- if not config.has_glow or config.glow_size <= 0:
- return text_layer
-
- w, h = text_layer.size
- alpha = np.array(text_layer.getchannel("A"), dtype=np.uint8)
- _, binary_mask = cv2.threshold(alpha, 127, 255, cv2.THRESH_BINARY)
- binary_inv = cv2.bitwise_not(binary_mask)
- distance = cv2.distanceTransform(binary_inv, cv2.DIST_L2, cv2.DIST_MASK_3)
- distance = distance.astype(np.float32)
- max_distance = float(config.glow_size)
- if max_distance > 0:
- glow_intensity = np.clip(1.0 - (distance / max_distance), 0, 1)
- else:
- glow_intensity = np.zeros_like(distance)
- if config.glow_spread > 0:
- glow_zone = binary_mask.copy()
- kernel_size = config.glow_spread * 2 + 1
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
- glow_zone = cv2.dilate(glow_zone, kernel, iterations=1)
- glow_zone = cv2.subtract(glow_zone, binary_mask)
- glow_zone_float = glow_zone.astype(np.float32) / 255.0
- glow_intensity = glow_intensity * glow_zone_float
- actual_brightness = self._remap_value(config.glow_brightness, 0.7, 1.0)
- glow_intensity = glow_intensity * actual_brightness
- glow_arr = np.clip(glow_intensity * 255, 0, 255).astype(np.uint8)
- glow_rgba = np.zeros((h, w, 4), dtype=np.uint8)
- glow_rgba[:, :, 0] = config.glow_color[0]
- glow_rgba[:, :, 1] = config.glow_color[1]
- glow_rgba[:, :, 2] = config.glow_color[2]
- glow_rgba[:, :, 3] = glow_arr
- glow_colored = Image.fromarray(glow_rgba, 'RGBA')
- result = Image.new("RGBA", (w, h), (0, 0, 0, 0))
- result.paste(glow_colored, (0, 0), glow_colored)
- result.paste(text_layer, (0, 0), text_layer)
- return result
-
- def _apply_shadow_effect(self, text_layer: Image.Image, config: TextRenderConfig) -> Image.Image:
- alpha = text_layer.getchannel("A")
- shadow = Image.new("RGBA", text_layer.size, (0, 0, 0, 0))
- shadow.putalpha(alpha.copy())
- 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
- shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y),
- Image.Resampling.BICUBIC, fill=(0, 0, 0, 0))
- shadow_data = np.array(shadow)
- mask = shadow_data[:, :, 3] > 0
- actual_shadow_opacity = self._remap_value(config.shadow_opacity, 0.7, 1.0)
- shadow_data[:, :, 3] = (shadow_data[:, :, 3].astype(np.float32) * actual_shadow_opacity).astype(np.uint8)
- shadow_rgb = np.array([config.shadow_color[0], config.shadow_color[1], config.shadow_color[2]], dtype=np.uint8)
- shadow_data[mask, :3] = shadow_rgb
- shadow_colored = Image.fromarray(shadow_data, 'RGBA')
- result = Image.new("RGBA", text_layer.size, (0, 0, 0, 0))
- result.paste(shadow_colored, (0, 0), shadow_colored)
- result.paste(text_layer, (0, 0), text_layer)
- return result
-
- def render_text_layer(self, text: str, config: TextRenderConfig,
- container_size: Tuple[int, int]) -> Image.Image:
- cache_key = f"{text}_{id(config)}_{container_size}_{config.font.size}"
- cached = self._render_cache.get(cache_key)
- if cached is not None:
- return cached
- text_layer = self._render_text_core(text, config, container_size)
- if config.use_supersampling and config.supersampling_factor > 1:
- text_layer = self.quality_renderer.render_with_supersampling(
- text, config, container_size, self._render_text_core, config.supersampling_factor
- )
- if config.has_glow:
- text_layer = self._apply_glow_effect(text_layer, config)
- if config.has_shadow:
- text_layer = self._apply_shadow_effect(text_layer, config)
- if config.use_edge_smoothing:
- text_layer = self.quality_renderer.apply_edge_smoothing(text_layer)
- self._render_cache.put(cache_key, text_layer)
- return text_layer
-
- def _process_single(self, image, mask, text, font_name, config, padding,
- vertical_align, horizontal_align, rotate_with_mask,
- min_font_size, max_font_size):
- with self.measure_time('mask_process_time'):
- 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)
- if font_path is None:
- self.log(f"Font not found: {font_name}, using default", level=0)
- font_path = self.get_font_path("default")
- pbar = ComfyProgressBar(max_font_size - min_font_size, "Finding font size") if HAS_PBAR else None
- with self.measure_time('font_find_time'):
- config.font, actual_size = self.find_optimal_font_size(
- font_path, text, available_w, available_h,
- min_font_size, max_font_size, config, pbar
- )
- with self.measure_time('render_time'):
- 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.Resampling.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]:
- with self.measure_time('total_time'):
- try:
- self._instance_color_cache.clear()
- if self._render_cache:
- self._render_cache.clear()
- self._mask_cache.clear()
- self._text_dim_cache.clear()
- self._contour_cache.clear()
- if not self._validate_inputs(image, mask):
- self.log("Input validation failed", level=0)
- return (image,)
- data = self._parse_and_validate_node_data(node_data)
- text = data.get('text', 'Текст')
- if not text or not text.strip():
- self.log("Empty text, using default", level=1)
- text = "Текст"
- font_name = data.get('font_name', 'default')
- self.log(f"Rendering text: '{text[:50]}' | Font: {font_name}", level=2)
- 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)
- enable_shadow = data.get('enable_shadow', False)
- shadow_color = data.get('shadow_color', '#333333')
- shadow_offset_x = data.get('shadow_offset_x', 10)
- shadow_offset_y = data.get('shadow_offset_y', 10)
- shadow_blur = data.get('shadow_blur', 0.2)
- shadow_opacity = data.get('shadow_opacity', 0.8)
- enable_glow = data.get('enable_glow', False)
- glow_color = data.get('glow_color', '#FFFFFF')
- glow_size = data.get('glow_size', 150)
- glow_spread = data.get('glow_spread', 250)
- glow_brightness = data.get('glow_brightness', 1.0)
- use_supersampling = data.get('use_supersampling', False)
- use_edge_smoothing = data.get('use_edge_smoothing', True)
- supersampling_factor = data.get('supersampling_factor', 2)
- min_font_size = data.get('min_font_size', 8)
- max_font_size = data.get('max_font_size', 500)
- padding = data.get('padding', 5)
-
- actual_text_opacity = self._remap_value(text_opacity, 0.7, 1.0)
-
- config = TextRenderConfig(
- font=None,
- text_color=self.parse_color(text_color, actual_text_opacity),
- outline_color=self.parse_color(outline_color, actual_text_opacity),
- outline_thickness=outline_thickness,
- letter_spacing=letter_spacing,
- line_spacing=line_spacing,
- enable_shadow=enable_shadow,
- shadow_color=self.parse_color(shadow_color, 1.0),
- shadow_offset=(shadow_offset_x, shadow_offset_y),
- shadow_blur=shadow_blur,
- shadow_opacity=shadow_opacity,
- enable_glow=enable_glow,
- glow_color=self.parse_color(glow_color, 1.0),
- glow_size=glow_size,
- glow_spread=glow_spread,
- glow_brightness=glow_brightness,
- text_opacity=int(actual_text_opacity * 100),
- use_supersampling=use_supersampling,
- use_edge_smoothing=use_edge_smoothing,
- supersampling_factor=supersampling_factor
- )
- 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, padding, 'center', 'center',
- rotate_with_mask, min_font_size, max_font_size
- )
- results.append(result)
- result_tensor = torch.cat(results, dim=0)
- else:
- result_tensor = self._process_single(
- image, mask, text, font_name, config, padding,
- 'center', 'center', rotate_with_mask, min_font_size, max_font_size
- )
- return (result_tensor,)
- except Exception as e:
- self.log(f"Error in apply_text: {type(e).__name__}: {e}", level=0)
- import traceback
- traceback.print_exc()
- return (image,)
-
- def __del__(self):
- self._font_cache.clear()
- self._text_dim_cache.clear()
- self._contour_cache.clear()
- if self._render_cache:
- self._render_cache.clear()
- if hasattr(self, '_mask_cache'):
- self._mask_cache.clear()
- if hasattr(self, '_instance_color_cache'):
- self._instance_color_cache.clear()
- if torch.cuda.is_available():
- torch.cuda.empty_cache()
-
-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"]
\ No newline at end of file
diff --git a/Rayko_Text_Overlay_Pro.py b/Rayko_Text_Overlay_Pro.py
new file mode 100644
index 0000000..c023162
--- /dev/null
+++ b/Rayko_Text_Overlay_Pro.py
@@ -0,0 +1,1269 @@
+from scipy import ndimage
+import os
+import torch
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont, ImageFilter
+import functools
+from typing import Dict, Tuple, List, Optional, Any
+from dataclasses import dataclass
+import time
+import re
+from collections import OrderedDict
+import folder_paths
+from server import PromptServer
+from aiohttp import web
+import freetype
+import cairo
+import cv2
+import json
+import asyncio
+
+class LRUCache:
+ def __init__(self, max_size: int = 100):
+ self.cache = OrderedDict()
+ self.max_size = max_size
+
+ def get(self, key):
+ if key not in self.cache:
+ return None
+ self.cache.move_to_end(key)
+ return self.cache[key]
+
+ def put(self, key, value):
+ if key in self.cache:
+ self.cache.move_to_end(key)
+ self.cache[key] = value
+ if len(self.cache) > self.max_size:
+ self.cache.popitem(last=False)
+
+ def clear(self):
+ self.cache.clear()
+
+TRANSFORM_CACHE = LRUCache(max_size=50)
+PENDING_TRANSFORMS = LRUCache(max_size=50)
+RENDER_CACHE = LRUCache(max_size=100)
+
+GLOBAL_RENDERER = None
+
+def get_renderer():
+ global GLOBAL_RENDERER
+ if GLOBAL_RENDERER is None:
+ GLOBAL_RENDERER = RS_OverlayPro()
+ return GLOBAL_RENDERER
+
+def safe_font_path(font_name: str) -> Optional[str]:
+ current_dir = os.path.dirname(os.path.abspath(__file__))
+ fonts_dir = os.path.join(current_dir, "fonts")
+ if os.path.basename(font_name) != font_name:
+ return None
+ path = os.path.join(fonts_dir, font_name)
+ if os.path.isfile(path):
+ return path
+ return None
+
+@PromptServer.instance.routes.post("/rayko/rs_overlay_pro")
+async def transform_handler(request):
+ try:
+ data = await request.json()
+ node_id = str(data.get("id"))
+ transforms = data.get("transforms")
+ text_params = data.get("text_params", {})
+
+ if node_id is None or transforms is None:
+ return web.json_response({"error": "Missing data"}, status=400)
+
+ required = {"x", "y", "rotation", "base_width_px", "base_height_px"}
+ if not all(k in transforms for k in required):
+ return web.json_response({"error": "Invalid transform data"}, status=400)
+
+ if not isinstance(text_params, dict):
+ return web.json_response({"error": "text_params must be object"}, status=400)
+
+ TRANSFORM_CACHE.put(node_id, {
+ **transforms,
+ "text_params": text_params
+ })
+
+ if node_id in PENDING_TRANSFORMS.cache:
+ PENDING_TRANSFORMS.cache[node_id]["status"] = "completed"
+
+ return web.json_response({"status": "ok"})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+@PromptServer.instance.routes.get("/rayko/rs_overlay_pro/get_fonts")
+async def get_fonts_handler(request):
+ try:
+ renderer = get_renderer()
+ font_list = renderer._get_font_list()
+ return web.json_response({"font_list": font_list, "default_font": font_list[0] if font_list else ""})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/cancel")
+async def cancel_handler(request):
+ try:
+ data = await request.json()
+ node_id = str(data.get("node_id"))
+ if node_id:
+ PENDING_TRANSFORMS.cache[node_id] = {"status": "cancelled"}
+ return web.json_response({"status": "cancelled"})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/cleanup")
+async def cleanup_handler(request):
+ try:
+ data = await request.json()
+ node_id = str(data.get("node_id"))
+ TRANSFORM_CACHE.cache.pop(node_id, None)
+ PENDING_TRANSFORMS.cache.pop(node_id, None)
+
+ temp_dir = folder_paths.get_temp_directory()
+ for f in os.listdir(temp_dir):
+ if f.startswith(f"rs_overlay_pro_{node_id}_"):
+ try:
+ os.remove(os.path.join(temp_dir, f))
+ except:
+ pass
+
+ return web.json_response({"status": "ok"})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_masks")
+async def render_masks_handler(request):
+ try:
+ data = await request.json()
+ text_params = data.get("text_params", {})
+ container_width = int(data.get("container_width", 400))
+ container_height = int(data.get("container_height", 200))
+ node_id = str(data.get("node_id", "unknown"))
+
+ if container_width <= 0 or container_height <= 0:
+ return web.json_response({"error": "Invalid container size"}, status=400)
+
+ renderer = get_renderer()
+ masks, render_size = renderer.render_masks_from_params(text_params, (container_width, container_height))
+
+ ts = int(time.time() * 1000)
+ result = {
+ "width": render_size[0],
+ "height": render_size[1],
+ "timestamp": ts
+ }
+
+ for mask_name, mask_img in masks.items():
+ filename = f"rs_overlay_pro_{node_id}_{ts}_{mask_name}.png"
+ filepath = os.path.join(folder_paths.get_temp_directory(), filename)
+ mask_img.save(filepath)
+ result[f"{mask_name}_file"] = filename
+
+ return web.json_response(result)
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ return web.json_response({"error": str(e)}, status=500)
+
+@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_text")
+async def render_text_handler(request):
+ try:
+ data = await request.json()
+ text_params = data.get("text_params", {})
+ container_width = int(data.get("container_width", 400))
+ container_height = int(data.get("container_height", 200))
+ node_id = str(data.get("node_id", "unknown"))
+
+ if container_width <= 0 or container_height <= 0:
+ return web.json_response({"error": "Invalid container size"}, status=400)
+
+ renderer = get_renderer()
+ text_layer = renderer.render_text_from_params(text_params, (container_width, container_height))
+
+ ts = int(time.time() * 1000)
+ filename = f"rs_overlay_pro_{node_id}_{ts}_text.png"
+ filepath = os.path.join(folder_paths.get_temp_directory(), filename)
+ text_layer.save(filepath)
+
+ return web.json_response({
+ "filename": filename,
+ "width": text_layer.width,
+ "height": text_layer.height,
+ "timestamp": ts
+ })
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ return web.json_response({"error": str(e)}, status=500)
+
+@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_glow_mask")
+async def render_glow_mask_handler(request):
+ try:
+ data = await request.json()
+ text_mask_file = data.get("text_mask_file")
+ glow_size = int(data.get("glow_size", 0))
+ glow_spread = int(data.get("glow_spread", 0))
+ glow_opacity = float(data.get("glow_opacity", 1.0))
+ node_id = str(data.get("node_id", "unknown"))
+
+ if not text_mask_file:
+ return web.json_response({"error": "Missing text_mask_file"}, status=400)
+
+ temp_dir = folder_paths.get_temp_directory()
+ mask_path = os.path.join(temp_dir, text_mask_file)
+
+ if not os.path.isfile(mask_path):
+ return web.json_response({"error": "text_mask_file not found"}, status=404)
+
+ cache_key = f"{text_mask_file}_{glow_size}_{glow_spread}_{glow_opacity}"
+ cached = RENDER_CACHE.get(cache_key)
+ if cached:
+ return web.json_response(cached)
+
+ text_mask = Image.open(mask_path).convert("RGBA")
+ w, h = text_mask.size
+
+ extra_padding = 0
+ if glow_size > 0:
+ extra_padding = int(glow_size * 1.0 + glow_spread * 0.3) + 10
+ extra_padding = min(extra_padding, 300)
+
+ expanded_w = w + 2 * extra_padding
+ expanded_h = h + 2 * extra_padding
+ expanded_mask = Image.new("RGBA", (expanded_w, expanded_h), (0, 0, 0, 0))
+ paste_x = extra_padding
+ paste_y = extra_padding
+ expanded_mask.paste(text_mask, (paste_x, paste_y), text_mask)
+
+ renderer = get_renderer()
+ glow_mask = renderer._apply_glow_to_mask(
+ expanded_mask, glow_size, glow_spread, glow_opacity
+ )
+
+ ts = int(time.time() * 1000)
+ filename = f"rs_overlay_pro_{node_id}_{ts}_glow.png"
+ filepath = os.path.join(temp_dir, filename)
+ glow_mask.save(filepath)
+
+ response_data = {
+ "glow_file": filename,
+ "width": glow_mask.width,
+ "height": glow_mask.height,
+ "timestamp": ts,
+ "extra_padding": extra_padding
+ }
+ RENDER_CACHE.put(cache_key, response_data)
+ return web.json_response(response_data)
+
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ return web.json_response({"error": str(e)}, status=500)
+
+@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_shadow_mask")
+async def render_shadow_mask(request):
+ try:
+ data = await request.json()
+ text_mask_file = data.get("text_mask_file")
+ shadow_blur = int(data.get("shadow_blur", 0))
+ node_id = data.get("node_id", "0")
+
+ if not text_mask_file:
+ return web.json_response({"error": "text_mask_file is required"}, status=400)
+
+ temp_dir = folder_paths.get_temp_directory()
+ text_mask_path = os.path.join(temp_dir, text_mask_file)
+
+ if not os.path.exists(text_mask_path):
+ print(f"[RS Overlay Pro] ERROR: file does not exist at {text_mask_path}")
+ return web.json_response({"error": "text_mask_file does not exist"}, status=404)
+
+ img = Image.open(text_mask_path).convert("L")
+
+ if shadow_blur > 0:
+ from PIL import ImageFilter
+ img = img.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
+
+ rgba = Image.new("RGBA", img.size, (0, 0, 0, 0))
+ rgba.putalpha(img)
+
+ timestamp = int(time.time() * 1000)
+ out_name = f"shadow_{node_id}_{timestamp}.png"
+ out_path = os.path.join(temp_dir, out_name)
+ rgba.save(out_path, optimize=True)
+
+ return web.json_response({
+ "shadow_file": out_name,
+ "timestamp": timestamp
+ })
+
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ return web.json_response({"error": str(e)}, status=500)
+
+@dataclass
+class TextRenderConfig:
+ font: ImageFont.ImageFont
+ font_path: str
+ font_size: int
+ text_color: Tuple[int, int, int, int]
+ outline_color: Tuple[int, int, int, int]
+ outline_thickness: int
+ text_opacity: int = 100
+ outline_opacity: int = 100
+ effect_mode: str = "on"
+ text_scale_x: float = 1.0
+ text_scale_y: float = 1.0
+ text_align: str = "left"
+ line_spacing: float = 1.0
+ letter_spacing: float = 0.0
+ enable_glow: bool = False
+ glow_color: Tuple[int, int, int, int] = (255, 255, 255, 255)
+ glow_size: int = 100
+ glow_spread: int = 150
+ glow_opacity: float = 1.0
+ enable_shadow: bool = False
+ shadow_color: Tuple[int, int, int, int] = (51, 51, 51, 255)
+ shadow_offset_x: int = 10
+ shadow_offset_y: int = 10
+ shadow_opacity: float = 0.8
+ shadow_blur: int = 0
+
+ @property
+ def has_outline(self) -> bool:
+ return self.effect_mode == "on" and self.outline_thickness > 0
+
+class HybridTextRenderer:
+ @staticmethod
+ def render_text_mask_pillow(font_path: str, font_size: int, text: str,
+ container_size: Tuple[int, int], text_align: str = "left",
+ padding_x: int = 20, padding_y: int = 25,
+ line_spacing: float = 1.0,
+ letter_spacing: float = 0.0) -> Image.Image:
+ width, height = container_size
+ text_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(text_layer)
+
+ try:
+ font = ImageFont.truetype(font_path, font_size, layout_engine=ImageFont.Layout.BASIC)
+ except:
+ font = ImageFont.load_default()
+
+ lines = text.split('\n')
+ if not lines:
+ return text_layer
+
+ first_bbox = draw.textbbox((0, 0), lines[0], font=font)
+ line_height = first_bbox[3] - first_bbox[1]
+ if line_height == 0:
+ line_height = font_size
+
+ total_text_height = line_height * len(lines) + (len(lines) - 1) * line_height * (line_spacing - 1)
+ available_height = height - 2 * padding_y
+ y_start = padding_y + (available_height - total_text_height) / 2
+ baseline_offset = -first_bbox[1]
+ y_start += baseline_offset
+
+ current_y = y_start
+
+ for line in lines:
+ if not line:
+ current_y += line_height * line_spacing
+ continue
+
+ line_bbox = draw.textbbox((0, 0), line, font=font)
+ line_width = line_bbox[2] - line_bbox[0]
+ if letter_spacing != 0.0 and len(line) > 1:
+ line_width += letter_spacing * (len(line) - 1)
+
+ available_width = width - 2 * padding_x
+ if text_align == "left":
+ x = padding_x - line_bbox[0]
+ elif text_align == "center":
+ x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
+ elif text_align == "right":
+ x = width - padding_x - line_width - line_bbox[0]
+ else:
+ x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
+
+ if letter_spacing != 0.0 and len(line) > 0:
+ pen_x = x
+ for char in line:
+ draw.text((pen_x, current_y), char, font=font, fill=(255, 255, 255, 255))
+ char_width = draw.textlength(char, font=font)
+ pen_x += char_width + letter_spacing
+ else:
+ draw.text((x, current_y), line, font=font, fill=(255, 255, 255, 255))
+
+ current_y += line_height * line_spacing
+
+ return text_layer
+
+ @staticmethod
+ def render_outline_mask_pillow(font_path: str, font_size: int, text: str,
+ outline_thickness: int, container_size: Tuple[int, int],
+ text_align: str = "left",
+ padding_x: int = 20, padding_y: int = 25,
+ line_spacing: float = 1.0,
+ letter_spacing: float = 0.0) -> Image.Image:
+ if outline_thickness <= 0:
+ return Image.new("RGBA", container_size, (0, 0, 0, 0))
+
+ width, height = container_size
+ text_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(text_layer)
+
+ try:
+ font = ImageFont.truetype(font_path, font_size, layout_engine=ImageFont.Layout.BASIC)
+ except:
+ font = ImageFont.load_default()
+
+ lines = text.split('\n')
+ if not lines:
+ return text_layer
+
+ first_bbox = draw.textbbox((0, 0), lines[0], font=font)
+ line_height = first_bbox[3] - first_bbox[1]
+ if line_height == 0:
+ line_height = font_size
+
+ total_text_height = line_height * len(lines) + (len(lines) - 1) * line_height * (line_spacing - 1)
+ available_height = height - 2 * padding_y
+ y_start = padding_y + (available_height - total_text_height) / 2
+ baseline_offset = -first_bbox[1]
+ y_start += baseline_offset
+
+ current_y = y_start
+
+ for line in lines:
+ if not line:
+ current_y += line_height * line_spacing
+ continue
+
+ line_bbox = draw.textbbox((0, 0), line, font=font)
+ line_width = line_bbox[2] - line_bbox[0]
+ if letter_spacing != 0.0 and len(line) > 1:
+ line_width += letter_spacing * (len(line) - 1)
+
+ available_width = width - 2 * padding_x
+ if text_align == "left":
+ x = padding_x - line_bbox[0]
+ elif text_align == "center":
+ x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
+ elif text_align == "right":
+ x = width - padding_x - line_width - line_bbox[0]
+ else:
+ x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
+
+ if letter_spacing != 0.0 and len(line) > 0:
+ pen_x = x
+ for char in line:
+ draw.text((pen_x, current_y), char, font=font,
+ fill=(255, 255, 255, 255),
+ stroke_width=outline_thickness,
+ stroke_fill=(255, 255, 255, 255))
+ char_width = draw.textlength(char, font=font)
+ pen_x += char_width + letter_spacing
+ else:
+ draw.text((x, current_y), line, font=font,
+ fill=(255, 255, 255, 255),
+ stroke_width=outline_thickness,
+ stroke_fill=(255, 255, 255, 255))
+
+ current_y += line_height * line_spacing
+
+ return text_layer
+
+ @staticmethod
+ def apply_cairo_transform(image: Image.Image, scale_x: float, scale_y: float) -> Image.Image:
+ if abs(scale_x - 1.0) < 0.01 and abs(scale_y - 1.0) < 0.01:
+ return image
+
+ width, height = image.size
+ result_width = max(1, int(width * abs(scale_x)))
+ result_height = max(1, int(height * abs(scale_y)))
+
+ arr = np.array(image)
+ bgra = np.zeros_like(arr)
+ bgra[:, :, 0] = arr[:, :, 2]
+ bgra[:, :, 1] = arr[:, :, 1]
+ bgra[:, :, 2] = arr[:, :, 0]
+ bgra[:, :, 3] = arr[:, :, 3]
+
+ data = bgra.tobytes()
+ source_surface = cairo.ImageSurface.create_for_data(
+ bytearray(data), cairo.FORMAT_ARGB32, width, height
+ )
+
+ result_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, result_width, result_height)
+ ctx = cairo.Context(result_surface)
+ ctx.set_source_rgba(0, 0, 0, 0)
+ ctx.paint()
+
+ ctx.save()
+ ctx.translate(result_width / 2, result_height / 2)
+ ctx.scale(scale_x, scale_y)
+ ctx.translate(-width / 2, -height / 2)
+ ctx.set_source_surface(source_surface, 0, 0)
+ ctx.paint()
+ ctx.restore()
+
+ buf = result_surface.get_data()
+ arr = np.frombuffer(buf, dtype=np.uint8).reshape(result_height, result_width, 4)
+ rgba = np.zeros_like(arr)
+ rgba[:, :, 0] = arr[:, :, 2]
+ rgba[:, :, 1] = arr[:, :, 1]
+ rgba[:, :, 2] = arr[:, :, 0]
+ rgba[:, :, 3] = arr[:, :, 3]
+
+ return Image.fromarray(rgba, 'RGBA')
+
+class RS_OverlayPro:
+ WEB_DIRECTORY = "web"
+ _font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {}
+ _freetype_cache: Dict[Tuple[str, int], freetype.Face] = {}
+ _text_dim_cache: LRUCache = LRUCache(max_size=200)
+ _hex_color_pattern = re.compile(r'^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})?$')
+ _hex_short_pattern = re.compile(r'^#?([0-9A-Fa-f])([0-9A-Fa-f])([0-9A-Fa-f])$')
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ },
+ "hidden": {
+ "unique_id": "UNIQUE_ID"
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "composite"
+ CATEGORY = "🦊 RaykoStudio"
+ OUTPUT_NODE = True
+ DESCRIPTION = "Interactive node for overlaying text on images with real-time positioning, scaling, rotation, and text editing"
+
+ def __init__(self):
+ self.hybrid_renderer = HybridTextRenderer()
+
+ def get_font_path(self, font_name: str) -> Optional[str]:
+ return safe_font_path(font_name)
+
+ @functools.lru_cache(maxsize=64)
+ 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, layout_engine=ImageFont.Layout.BASIC)
+ font.path = font_path
+ font.size = size
+ self._font_cache[cache_key] = font
+ return font
+ except Exception as e:
+ raise Exception(f"Font error: {e}")
+
+ @functools.lru_cache(maxsize=64)
+ def get_freetype_face(self, font_path: str, size: int) -> freetype.Face:
+ cache_key = (font_path, size)
+ if cache_key in self._freetype_cache:
+ return self._freetype_cache[cache_key]
+
+ try:
+ face = freetype.Face(font_path)
+ face.set_char_size(size * 64)
+ self._freetype_cache[cache_key] = face
+ return face
+ except Exception as e:
+ raise Exception(f"FreeType 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))
+
+ try:
+ if isinstance(color, str):
+ color_str = color.strip()
+ match = self._hex_color_pattern.match(color_str)
+ if match:
+ r, g, b = int(match.group(1), 16), int(match.group(2), 16), int(match.group(3), 16)
+ a = int(match.group(4), 16) if match.group(4) else int(alpha * 255)
+ return (r, g, b, a)
+ else:
+ match_short = self._hex_short_pattern.match(color_str)
+ if match_short:
+ r, g, b = int(match_short.group(1)*2, 16), int(match_short.group(2)*2, 16), int(match_short.group(3)*2, 16)
+ a = int(alpha * 255)
+ return (r, g, b, a)
+ else:
+ return (255, 255, 255, int(alpha * 255))
+ else:
+ return (255, 255, 255, int(alpha * 255))
+ except Exception:
+ return (255, 255, 255, int(alpha * 255))
+
+ def _extract_text_params(self, text_params: dict, container_size: Tuple[int, int]) -> dict:
+ font_list = self._get_font_list()
+ text = text_params.get('text', '')
+ if not text or not text.strip():
+ text = ''
+
+ font_name = text_params.get('font_name', '')
+ if not font_name or font_name not in font_list:
+ font_name = font_list[0] if font_list else ''
+
+ font_path = self.get_font_path(font_name)
+ if font_path is None and font_list:
+ font_path = self.get_font_path(font_list[0])
+
+ text_color = text_params.get('text_color', '#FFFFFF')
+ outline_color = text_params.get('outline_color', '#808080')
+ outline_thickness = int(text_params.get('outline_thickness', 0))
+ effect_mode = str(text_params.get('effect_mode', 'on')).lower()
+ effect_mode = 'on' if effect_mode in ('on', 'off') else 'on'
+ text_scale_x = float(text_params.get('text_scale_x', 1.0))
+ text_scale_y = float(text_params.get('text_scale_y', 1.0))
+ text_align = str(text_params.get('text_align', 'left')).lower()
+ text_align = text_align if text_align in ('left', 'center', 'right') else 'left'
+ line_spacing = max(0.5, min(3.0, float(text_params.get('line_spacing', 1.0))))
+ letter_spacing = max(-20.0, min(100.0, float(text_params.get('letter_spacing', 0.0))))
+ min_font_size = int(text_params.get('min_font_size', 4))
+ max_font_size = int(text_params.get('max_font_size', 2000))
+ text_opacity = min(1.0, max(0.0, float(text_params.get('text_opacity', 1.0))))
+ outline_opacity = min(1.0, max(0.0, float(text_params.get('outline_opacity', 1.0))))
+ enable_glow = bool(text_params.get('enable_glow', False))
+ glow_color = text_params.get('glow_color', '#FFFFFF')
+ glow_size = int(text_params.get('glow_size', 0))
+ glow_spread = int(text_params.get('glow_spread', 0))
+ glow_opacity = min(1.0, max(0.0, float(text_params.get('glow_opacity', 1.0))))
+
+ padding_x = int(container_size[0] * 0.2) # 20% ширины
+ padding_y = int(container_size[1] * 0.15) # 15% высоты
+
+ return {
+ 'text': text,
+ 'font_name': font_name,
+ 'font_path': font_path,
+ 'text_color': text_color,
+ 'outline_color': outline_color,
+ 'outline_thickness': outline_thickness,
+ 'effect_mode': effect_mode,
+ 'text_scale_x': text_scale_x,
+ 'text_scale_y': text_scale_y,
+ 'text_align': text_align,
+ 'line_spacing': line_spacing,
+ 'letter_spacing': letter_spacing,
+ 'min_font_size': min_font_size,
+ 'max_font_size': max_font_size,
+ 'text_opacity': text_opacity,
+ 'outline_opacity': outline_opacity,
+ 'enable_glow': enable_glow,
+ 'glow_color': glow_color,
+ 'glow_size': glow_size,
+ 'glow_spread': glow_spread,
+ 'glow_opacity': glow_opacity,
+ 'padding_x': padding_x,
+ 'padding_y': padding_y,
+ }
+
+ def calculate_text_dimensions_freetype(self, font_path: str, size: int, text: str,
+ outline_thickness: int, line_spacing: float = 1.0,
+ letter_spacing: float = 0.0) -> Tuple[float, float, List[float], List[float]]:
+ cache_key = (f"{font_path}:{size}", text, outline_thickness, letter_spacing, line_spacing)
+ cached = self._text_dim_cache.get(cache_key)
+ if cached is not None:
+ return cached
+
+ try:
+ face = self.get_freetype_face(font_path, size)
+ except:
+ return self.calculate_text_dimensions_legacy(f"{font_path}:{size}", text, outline_thickness, line_spacing, letter_spacing)
+
+ ascender = face.size.ascender / 64
+ descender = face.size.descender / 64
+ line_height_fixed = ascender - descender
+
+ lines = text.split('\n')
+ line_heights = []
+ line_widths = []
+
+ for line in lines:
+ line = line.replace('\t', ' ')
+ if not line:
+ line_heights.append(line_height_fixed + outline_thickness * 2)
+ line_widths.append(0 + outline_thickness * 2)
+ continue
+
+ ink_left = float('inf')
+ ink_right = float('-inf')
+ pen_x = 0
+ prev_glyph_index = 0
+
+ for i, char in enumerate(line):
+ try:
+ face.load_char(char, freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP)
+ glyph = face.glyph
+ metrics = glyph.metrics
+
+ if i > 0 and prev_glyph_index != 0:
+ kerning = face.get_kerning(prev_glyph_index, glyph.index)
+ pen_x += kerning.x / 64
+
+ glyph_left = pen_x + metrics.horiBearingX / 64
+ glyph_right = glyph_left + metrics.width / 64
+
+ ink_left = min(ink_left, glyph_left)
+ ink_right = max(ink_right, glyph_right)
+
+ pen_x += metrics.horiAdvance / 64
+ if letter_spacing != 0.0:
+ pen_x += letter_spacing
+ prev_glyph_index = glyph.index
+ except Exception:
+ continue
+
+ if ink_left == float('inf'):
+ line_width = 0
+ else:
+ line_width = ink_right - ink_left
+
+ line_heights.append(line_height_fixed + outline_thickness * 2)
+ line_widths.append(line_width + outline_thickness * 2)
+
+ if line_heights:
+ total_height = sum(line_heights)
+ if len(line_heights) > 1:
+ avg_line_height = line_heights[0]
+ total_height += (len(line_heights) - 1) * avg_line_height * (line_spacing - 1)
+ else:
+ total_height = 0
+ max_width = max(line_widths) if line_widths else 0
+
+ result = (total_height, max_width, line_heights, line_widths)
+ self._text_dim_cache.put(cache_key, result)
+ return result
+
+ def calculate_text_dimensions_legacy(self, font_key: str, text: str, outline_thickness: int,
+ line_spacing: float = 1.0, letter_spacing: float = 0.0) -> Tuple[float, float, list, list]:
+ temp_img = Image.new("RGB", (1, 1))
+ draw = ImageDraw.Draw(temp_img)
+ font_path, size_str = font_key.rsplit(':', 1) if ':' in font_key else (font_key, '12')
+
+ try:
+ font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default()
+ except:
+ font = ImageFont.load_default()
+
+ lines = text.split('\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
+
+ bbox = draw.textbbox((0, 0), line, font=font)
+ line_width = draw.textlength(line, font=font)
+
+ if letter_spacing != 0.0 and len(line) > 1:
+ line_width += letter_spacing * (len(line) - 1)
+
+ line_height = (bbox[3] - bbox[1])
+ line_heights.append(line_height + outline_thickness * 2)
+ line_widths.append(line_width + outline_thickness * 2)
+
+ if line_heights:
+ total_height = sum(line_heights)
+ if len(line_heights) > 1:
+ avg_h = line_heights[0]
+ total_height += (len(line_heights) - 1) * avg_h * (line_spacing - 1)
+ else:
+ total_height = 0
+ max_width = max(line_widths) if line_widths else 0
+ return (total_height, max_width, line_heights, line_widths)
+
+ def text_fits(self, font_path: str, size: int, text: str, max_width: float, max_height: float,
+ outline_thickness: int, line_spacing: float = 1.0, letter_spacing: float = 0.0) -> bool:
+ total_height, max_line_width, _, _ = self.calculate_text_dimensions_freetype(
+ font_path, size, text, outline_thickness, line_spacing, letter_spacing
+ )
+ 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) -> Tuple[ImageFont.ImageFont, int]:
+ if font_path is None:
+ font_path = "default"
+
+ if self.text_fits(font_path, max_size, text, max_width, max_height,
+ config.outline_thickness,
+ config.line_spacing, config.letter_spacing):
+ try:
+ font = self.get_cached_font(font_path, max_size) if font_path != "default" else ImageFont.load_default()
+ if font_path != "default" and hasattr(font, 'path'):
+ font.path = font_path
+ font.size = max_size
+ return font, max_size
+ except:
+ pass
+
+ low, high = min_size, max_size
+ best_size, best_font = min_size, None
+ iterations = 0
+ max_iterations = 50
+
+ while low <= high and iterations < max_iterations:
+ mid = (low + high) // 2
+ iterations += 1
+
+ try:
+ font = self.get_cached_font(font_path, mid) if font_path != "default" else ImageFont.load_default()
+ except:
+ high = mid - 1
+ continue
+
+ if self.text_fits(font_path, mid, text, max_width, max_height,
+ config.outline_thickness,
+ config.line_spacing, config.letter_spacing):
+ best_font, best_size = font, mid
+ low = mid + 1
+ else:
+ high = mid - 1
+
+ if best_font is None:
+ try:
+ best_font = self.get_cached_font(font_path, min_size) if font_path != "default" else ImageFont.load_default()
+ if font_path != "default" and hasattr(best_font, 'path'):
+ best_font.path = font_path
+ best_font.size = min_size
+ except:
+ best_font = ImageFont.load_default()
+
+ return best_font, best_size
+
+ def _get_font_list(self) -> List[str]:
+ current_dir = os.path.dirname(os.path.abspath(__file__))
+ fonts_dir = os.path.join(current_dir, "fonts")
+ font_list = []
+
+ if os.path.isdir(fonts_dir):
+ for f in sorted(os.listdir(fonts_dir)):
+ if f.lower().endswith(('.ttf', '.otf', '.ttc')):
+ font_list.append(f)
+
+ font_list = [f for f in font_list if os.path.basename(f) == f]
+
+ if "Arial.ttf" in font_list:
+ font_list.remove("Arial.ttf")
+ font_list.insert(0, "Arial.ttf")
+
+ if not font_list:
+ font_list = ["Arial.ttf"]
+
+ return font_list
+
+ def render_masks_from_params(self, text_params: dict, container_size: Tuple[int, int]) -> Tuple[Dict[str, Image.Image], Tuple[int, int]]:
+ p = self._extract_text_params(text_params, container_size)
+ text = p['text']
+ if not text:
+ empty_mask = Image.new("RGBA", container_size, (0,0,0,0))
+ return {'text_mask': empty_mask, 'outline_mask': empty_mask}, container_size
+
+ font_path = p['font_path']
+ if font_path is None:
+ font_list = self._get_font_list()
+ font_path = self.get_font_path(font_list[0])
+
+ temp_config = TextRenderConfig(
+ font=None,
+ font_path=font_path,
+ font_size=0,
+ text_color=(255,255,255,255),
+ outline_color=(255,255,255,255),
+ outline_thickness=p['outline_thickness'],
+ effect_mode=p['effect_mode'],
+ text_scale_x=p['text_scale_x'],
+ text_scale_y=p['text_scale_y'],
+ text_align=p['text_align'],
+ line_spacing=p['line_spacing'],
+ letter_spacing=p['letter_spacing']
+ )
+
+ available_w = max(1, container_size[0] - 2 * p['padding_x'])
+ available_h = max(1, container_size[1] - 2 * p['padding_y'])
+
+ font, actual_size = self.find_optimal_font_size(
+ font_path, text, available_w, available_h,
+ p['min_font_size'], p['max_font_size'], temp_config
+ )
+
+ text_mask = self.hybrid_renderer.render_text_mask_pillow(
+ font_path, actual_size, text, container_size, p['text_align'],
+ p['padding_x'], p['padding_y'], p['line_spacing'], p['letter_spacing']
+ )
+ text_mask = self.hybrid_renderer.apply_cairo_transform(
+ text_mask, p['text_scale_x'], p['text_scale_y']
+ )
+
+ masks = {'text_mask': text_mask}
+
+ if p['effect_mode'] == "on" and p['outline_thickness'] > 0:
+ outline_mask = self.hybrid_renderer.render_outline_mask_pillow(
+ font_path, actual_size, text, p['outline_thickness'], container_size,
+ p['text_align'], p['padding_x'], p['padding_y'],
+ p['line_spacing'], p['letter_spacing']
+ )
+ text_mask_no_transform = self.hybrid_renderer.render_text_mask_pillow(
+ font_path, actual_size, text, container_size, p['text_align'],
+ p['padding_x'], p['padding_y'], p['line_spacing'], p['letter_spacing']
+ )
+ text_arr = np.array(text_mask_no_transform)
+ outline_arr = np.array(outline_mask)
+ text_alpha = text_arr[:, :, 3].astype(np.float32)
+ outline_alpha = outline_arr[:, :, 3].astype(np.float32)
+ outline_only_alpha = np.clip(outline_alpha - text_alpha, 0, 255).astype(np.uint8)
+
+ outline_only_arr = np.zeros_like(outline_arr)
+ outline_only_arr[:, :, 0] = 255
+ outline_only_arr[:, :, 1] = 255
+ outline_only_arr[:, :, 2] = 255
+ outline_only_arr[:, :, 3] = outline_only_alpha
+
+ outline_only_mask = Image.fromarray(outline_only_arr, 'RGBA')
+ outline_only_mask = self.hybrid_renderer.apply_cairo_transform(
+ outline_only_mask, p['text_scale_x'], p['text_scale_y']
+ )
+ masks['outline_mask'] = outline_only_mask
+ else:
+ masks['outline_mask'] = Image.new("RGBA", (text_mask.width, text_mask.height), (0,0,0,0))
+
+ render_size = (text_mask.width, text_mask.height)
+ return masks, render_size
+
+ def render_text_from_params(self, text_params: dict, container_size: Tuple[int, int], include_glow: bool = False) -> Image.Image:
+ p = self._extract_text_params(text_params, container_size)
+ text = p['text']
+ if not text:
+ return Image.new("RGBA", container_size, (0,0,0,0))
+
+ masks, render_size = self.render_masks_from_params(text_params, container_size)
+ text_mask = masks['text_mask']
+ outline_mask = masks['outline_mask']
+
+ # Извлекаем shadow-параметры
+ enable_shadow = bool(text_params.get('enable_shadow', False))
+ shadow_color = text_params.get('shadow_color', '#333333')
+ shadow_opacity = min(1.0, max(0.0, float(text_params.get('shadow_opacity', 0.8))))
+ shadow_offset_x = int(text_params.get('shadow_offset_x', 0))
+ shadow_offset_y = int(text_params.get('shadow_offset_y', 0))
+ shadow_blur = int(text_params.get('shadow_blur', 0))
+
+ config = TextRenderConfig(
+ font=None,
+ font_path=p['font_path'],
+ font_size=0,
+ text_color=self.parse_color(p['text_color'], p['text_opacity']),
+ outline_color=self.parse_color(p['outline_color'], p['outline_opacity']),
+ outline_thickness=p['outline_thickness'],
+ effect_mode=p['effect_mode'],
+ text_scale_x=p['text_scale_x'],
+ text_scale_y=p['text_scale_y'],
+ text_align=p['text_align'],
+ line_spacing=p['line_spacing'],
+ letter_spacing=p['letter_spacing'],
+ enable_glow=p['enable_glow'],
+ glow_color=self.parse_color(p['glow_color'], p['glow_opacity']),
+ glow_size=p['glow_size'],
+ glow_spread=p['glow_spread'],
+ glow_opacity=p['glow_opacity'],
+ enable_shadow=enable_shadow,
+ shadow_color=self.parse_color(shadow_color, shadow_opacity),
+ shadow_offset_x=shadow_offset_x,
+ shadow_offset_y=shadow_offset_y,
+ shadow_opacity=shadow_opacity,
+ shadow_blur=shadow_blur
+ )
+
+ result = Image.new("RGBA", render_size, (0,0,0,0))
+
+ # 1. GLOW
+ if include_glow and config.enable_glow and config.glow_size > 0:
+ glow_mask = self._apply_glow_to_mask(
+ text_mask, config.glow_size, config.glow_spread, config.glow_opacity
+ )
+ glow_colored = self._apply_color_to_mask(glow_mask, config.glow_color)
+ result = Image.alpha_composite(result, glow_colored)
+
+ # 2. SHADOW (под текстом и outline)
+ if config.enable_shadow:
+ shadow_mask = self._apply_shadow_to_mask(
+ text_mask, config.shadow_blur, config.shadow_offset_x, config.shadow_offset_y
+ )
+ shadow_colored = self._apply_color_to_mask(shadow_mask, config.shadow_color)
+ result = Image.alpha_composite(result, shadow_colored)
+
+ # 3. OUTLINE
+ if config.has_outline:
+ outline_colored = self._apply_color_to_mask(outline_mask, config.outline_color)
+ result = Image.alpha_composite(result, outline_colored)
+
+ # 4. TEXT
+ text_colored = self._apply_color_to_mask(text_mask, config.text_color)
+ result = Image.alpha_composite(result, text_colored)
+
+ return result
+
+ def _apply_glow_to_mask(self, text_mask: Image.Image, glow_size: int,
+ glow_spread: int, glow_opacity: float) -> Image.Image:
+ if glow_size <= 0:
+ return Image.new("RGBA", text_mask.size, (0, 0, 0, 0))
+
+ w, h = text_mask.size
+ alpha = np.array(text_mask.getchannel("A"), dtype=np.uint8)
+
+ _, binary = cv2.threshold(alpha, 127, 255, cv2.THRESH_BINARY)
+ original_mask = binary.copy()
+
+ if glow_spread > 0:
+ iterations = max(1, int(glow_spread / 15))
+ kernel = np.ones((3, 3), np.uint8)
+ binary = cv2.dilate(binary, kernel, iterations=iterations)
+
+ sigma = max(0.5, glow_size / 2.0)
+ blurred = cv2.GaussianBlur(binary, (0, 0), sigmaX=sigma, sigmaY=sigma)
+
+ glow_float = blurred.astype(np.float32) / 255.0
+ original_float = original_mask.astype(np.float32) / 255.0
+ glow_outer = glow_float * (1.0 - original_float)
+ glow_outer = glow_outer * glow_opacity
+
+ glow_arr = np.clip(glow_outer * 255, 0, 255).astype(np.uint8)
+ glow_rgba = np.zeros((h, w, 4), dtype=np.uint8)
+ glow_rgba[:, :, 3] = glow_arr
+
+ return Image.fromarray(glow_rgba, 'RGBA')
+
+ def _apply_color_to_mask(self, mask: Image.Image, color: Tuple[int, int, int, int]) -> Image.Image:
+ arr = np.array(mask)
+ alpha = arr[:, :, 3]
+
+ colored_arr = np.zeros_like(arr)
+ colored_arr[:, :, 0] = color[0]
+ colored_arr[:, :, 1] = color[1]
+ colored_arr[:, :, 2] = color[2]
+ colored_arr[:, :, 3] = (alpha.astype(np.float32) * (color[3] / 255.0)).astype(np.uint8)
+
+ return Image.fromarray(colored_arr, 'RGBA')
+
+ def _apply_shadow_to_mask(self, text_mask: Image.Image, shadow_blur: int,
+ shadow_offset_x: int, shadow_offset_y: int) -> Image.Image:
+ from scipy import ndimage
+
+ w, h = text_mask.size
+
+ alpha = np.array(text_mask.getchannel("A"), dtype=np.float32)
+
+ if shadow_blur > 0:
+ alpha = ndimage.gaussian_filter(alpha, sigma=shadow_blur / 2.0)
+
+ shadow_alpha = np.zeros((h, w), dtype=np.float32)
+
+ if shadow_offset_x >= 0:
+ src_x_start, src_x_end = 0, w - shadow_offset_x
+ dst_x_start, dst_x_end = shadow_offset_x, w
+ else:
+ src_x_start, src_x_end = -shadow_offset_x, w
+ dst_x_start, dst_x_end = 0, w + shadow_offset_x
+
+ if shadow_offset_y >= 0:
+ src_y_start, src_y_end = 0, h - shadow_offset_y
+ dst_y_start, dst_y_end = shadow_offset_y, h
+ else:
+ src_y_start, src_y_end = -shadow_offset_y, h
+ dst_y_start, dst_y_end = 0, h + shadow_offset_y
+
+ src_x_start = max(0, src_x_start)
+ src_x_end = max(0, min(w, src_x_end))
+ src_y_start = max(0, src_y_start)
+ src_y_end = max(0, min(h, src_y_end))
+ dst_x_start = max(0, dst_x_start)
+ dst_x_end = max(0, min(w, dst_x_end))
+ dst_y_start = max(0, dst_y_start)
+ dst_y_end = max(0, min(h, dst_y_end))
+
+ copy_w = min(src_x_end - src_x_start, dst_x_end - dst_x_start)
+ copy_h = min(src_y_end - src_y_start, dst_y_end - dst_y_start)
+
+ if copy_w > 0 and copy_h > 0:
+ shadow_alpha[dst_y_start:dst_y_start+copy_h, dst_x_start:dst_x_start+copy_w] = \
+ alpha[src_y_start:src_y_start+copy_h, src_x_start:src_x_start+copy_w]
+
+ shadow_rgba = np.zeros((h, w, 4), dtype=np.uint8)
+ shadow_rgba[:, :, 3] = np.clip(shadow_alpha, 0, 255).astype(np.uint8)
+
+ return Image.fromarray(shadow_rgba, 'RGBA')
+
+ def _calculate_extra_padding(self, text_params: dict) -> int:
+ extra = 0
+ if text_params.get('enable_glow', False):
+ glow_size = int(text_params.get('glow_size', 0))
+ glow_spread = int(text_params.get('glow_spread', 0))
+ if glow_size > 0:
+ extra = max(extra, int(glow_size * 1.0 + glow_spread * 0.3) + 10)
+ if text_params.get('enable_shadow', False):
+ shadow_offset_x = abs(int(text_params.get('shadow_offset_x', 0)))
+ shadow_offset_y = abs(int(text_params.get('shadow_offset_y', 0)))
+ shadow_extra = max(shadow_offset_x, shadow_offset_y) + 20
+ extra = max(extra, shadow_extra)
+ extra = min(extra, 300)
+ return extra
+
+ def tensor2pil(self, tensor):
+ if len(tensor.shape) == 4:
+ if tensor.shape[0] > 1:
+ tensor = tensor[0]
+ arr = (tensor.cpu().numpy() * 255).astype(np.uint8)
+ while arr.ndim > 3:
+ arr = arr[0]
+ if arr.ndim < 3:
+ arr = np.expand_dims(arr, axis=-1)
+ c = arr.shape[2]
+ if c >= 4:
+ return Image.fromarray(arr[:,:,:4], 'RGBA')
+ if c == 3:
+ return Image.fromarray(arr, 'RGB')
+ return Image.fromarray(arr[:,:,0], 'L')
+
+ def pil2tensor(self, pil):
+ if pil.mode == 'L':
+ pil = pil.convert('RGB')
+ elif pil.mode != 'RGB':
+ pass
+ return torch.from_numpy(np.array(pil).astype(np.float32) / 255.0).unsqueeze(0)
+
+ def composite(self, image, unique_id=None):
+ unique_id = str(unique_id) if unique_id is not None else "unknown"
+
+ if image.numel() == 0:
+ return (image,)
+
+ h, w = image.shape[1], image.shape[2]
+ if h < 8 or w < 8:
+ return (image,)
+
+ TRANSFORM_CACHE.cache.pop(unique_id, None)
+
+ bg_pil = self.tensor2pil(image)
+ bg_w, bg_h = bg_pil.size
+
+ font_list = self._get_font_list()
+
+ ts = int(time.time() * 1000)
+ td = folder_paths.get_temp_directory()
+
+ for f in os.listdir(td):
+ if f.startswith(f"rs_overlay_pro_{unique_id}_"):
+ try:
+ os.remove(os.path.join(td, f))
+ except:
+ pass
+
+ bfn = f"rs_overlay_pro_{unique_id}_{ts}_bg.png"
+ bg_pil.save(os.path.join(td, bfn))
+
+ PENDING_TRANSFORMS.cache[unique_id] = {"status": "pending"}
+
+ PromptServer.instance.send_sync("rs-overlay-pro-start", {
+ "id": unique_id,
+ "bg_file": bfn,
+ "bg_width": bg_w,
+ "bg_height": bg_h,
+ "timestamp": ts,
+ "font_list": font_list,
+ "default_font": font_list[0]
+ })
+
+ print(f"[RS Overlay Pro] Node {unique_id} waiting for user input (Apply/Cancel)...")
+
+ timeout = 300
+ start_time = time.time()
+ while unique_id not in TRANSFORM_CACHE.cache:
+ if unique_id in PENDING_TRANSFORMS.cache and PENDING_TRANSFORMS.cache[unique_id].get("status") == "cancelled":
+ break
+ if time.time() - start_time > timeout:
+ print(f"[RS Overlay Pro] Timeout for node {unique_id}")
+ PENDING_TRANSFORMS.cache.pop(unique_id, None)
+ try:
+ os.remove(os.path.join(td, bfn))
+ except:
+ pass
+ return (self.pil2tensor(bg_pil),)
+ time.sleep(0.2)
+
+ if unique_id in PENDING_TRANSFORMS.cache:
+ if PENDING_TRANSFORMS.cache[unique_id].get("status") == "cancelled":
+ print(f"[RS Overlay Pro] Cancelled by user for node {unique_id}")
+ PENDING_TRANSFORMS.cache.pop(unique_id, None)
+ try:
+ os.remove(os.path.join(td, bfn))
+ except:
+ pass
+ return (self.pil2tensor(bg_pil),)
+
+ print(f"[RS Overlay Pro] Input received for node {unique_id}")
+ PENDING_TRANSFORMS.cache.pop(unique_id, None)
+
+ data = TRANSFORM_CACHE.cache.pop(unique_id, None)
+ if data is None:
+ return (self.pil2tensor(bg_pil),)
+
+ text_params = data.get("text_params", {})
+
+ text = text_params.get('text', '')
+ if not text or not text.strip():
+ try:
+ os.remove(os.path.join(td, bfn))
+ except:
+ pass
+ return (self.pil2tensor(bg_pil),)
+
+ rx = bg_w / 2 if data.get("x") is None else float(data.get("x"))
+ ry = bg_h / 2 if data.get("y") is None else float(data.get("y"))
+ rot = float(data.get("rotation") or 0)
+
+ base_width_px = float(text_params.get('base_width_px', bg_w))
+ base_height_px = float(text_params.get('base_height_px', bg_w * 3 / 5))
+ base_width_px = max(10, min(bg_w, base_width_px))
+ base_height_px = max(10, min(bg_h, base_height_px))
+
+ text_layer = self.render_text_from_params(
+ text_params, (int(base_width_px), int(base_height_px)),
+ include_glow=True
+ )
+
+ if rot != 0:
+ text_layer = text_layer.rotate(-rot, expand=True, resample=Image.Resampling.BICUBIC,
+ center=(text_layer.width//2, text_layer.height//2))
+
+ result = bg_pil.copy().convert("RGBA")
+ paste_x = int(rx - text_layer.width // 2)
+ paste_y = int(ry - text_layer.height // 2)
+ result.paste(text_layer, (paste_x, paste_y), text_layer if text_layer.mode == 'RGBA' else None)
+
+ try:
+ os.remove(os.path.join(td, bfn))
+ except:
+ pass
+
+ return (self.pil2tensor(result.convert("RGB")),)
+
+ @classmethod
+ def IS_CHANGED(cls, **kwargs):
+ return time.time()
+
+NODE_CLASS_MAPPINGS = {"RS_OverlayPro": RS_OverlayPro}
+NODE_DISPLAY_NAME_MAPPINGS = {"RS_OverlayPro": "🦊 RS Text Overlay Pro"}
\ No newline at end of file
diff --git a/__init__.py b/__init__.py
index fe0a8ec..670ef80 100644
--- a/__init__.py
+++ b/__init__.py
@@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'}
REQUIRED_MODULES: Set[str] = set()
PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio"
-__version__ = "0.25.2"
+__version__ = "0.26.0"
def load_modules():
print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}")
diff --git a/fonts/AlGhazalia Arab.otf b/fonts/AlGhazalia Arab.otf
new file mode 100644
index 0000000..d8821d0
Binary files /dev/null and b/fonts/AlGhazalia Arab.otf differ
diff --git a/fonts/Bbroken Detroit.otf b/fonts/Bbroken Detroit.otf
new file mode 100644
index 0000000..014953a
Binary files /dev/null and b/fonts/Bbroken Detroit.otf differ
diff --git a/fonts/Facon.otf b/fonts/Facon.otf
new file mode 100644
index 0000000..27b104b
Binary files /dev/null and b/fonts/Facon.otf differ
diff --git a/fonts/Fatboy.ttf b/fonts/Fatboy.ttf
new file mode 100644
index 0000000..2f9080c
Binary files /dev/null and b/fonts/Fatboy.ttf differ
diff --git a/fonts/Foglihten.otf b/fonts/Foglihten.otf
new file mode 100644
index 0000000..5a3109b
Binary files /dev/null and b/fonts/Foglihten.otf differ
diff --git a/fonts/Keetano Katakana.ttf b/fonts/Keetano Katakana.ttf
new file mode 100644
index 0000000..92a2a7f
Binary files /dev/null and b/fonts/Keetano Katakana.ttf differ
diff --git a/fonts/Legend.ttf b/fonts/Legend.ttf
new file mode 100644
index 0000000..41f95e8
Binary files /dev/null and b/fonts/Legend.ttf differ
diff --git a/fonts/Project Space.otf b/fonts/Project Space.otf
new file mode 100644
index 0000000..ecc18c3
Binary files /dev/null and b/fonts/Project Space.otf differ
diff --git a/fonts/Roboto-Black.ttf b/fonts/Roboto-Black.ttf
new file mode 100644
index 0000000..86ec2b2
Binary files /dev/null and b/fonts/Roboto-Black.ttf differ
diff --git a/fonts/Roboto-Thin.ttf b/fonts/Roboto-Thin.ttf
new file mode 100644
index 0000000..861d63a
Binary files /dev/null and b/fonts/Roboto-Thin.ttf differ
diff --git a/fonts/RobotoSlab-Bold.ttf b/fonts/RobotoSlab-Bold.ttf
new file mode 100644
index 0000000..df5d1df
Binary files /dev/null and b/fonts/RobotoSlab-Bold.ttf differ
diff --git a/fonts/RobotoSlab-Regular.ttf b/fonts/RobotoSlab-Regular.ttf
new file mode 100644
index 0000000..eb52a79
Binary files /dev/null and b/fonts/RobotoSlab-Regular.ttf differ
diff --git a/fonts/Runic AltNo.ttf b/fonts/Runic AltNo.ttf
new file mode 100644
index 0000000..764c76c
Binary files /dev/null and b/fonts/Runic AltNo.ttf differ
diff --git a/fonts/Runic.ttf b/fonts/Runic.ttf
new file mode 100644
index 0000000..f83dbd5
Binary files /dev/null and b/fonts/Runic.ttf differ
diff --git a/fonts/Rusticus.otf b/fonts/Rusticus.otf
new file mode 100644
index 0000000..f1cb70a
Binary files /dev/null and b/fonts/Rusticus.otf differ
diff --git a/fonts/Zenter.otf b/fonts/Zenter.otf
new file mode 100644
index 0000000..ac3f0b1
Binary files /dev/null and b/fonts/Zenter.otf differ
diff --git a/package.json b/package.json
index fb2ddb1..ab8e710 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ComfyUI_RaykoStudio",
- "version": "0.25.2",
+ "version": "0.26.0",
"description": "Set of custom nodes for ComfyUI providing additional image processing capabilities",
"author": "Raykosan",
"license": "Apache License 2.0",
@@ -32,7 +32,7 @@
"Rayko_Save_Image_Pair.py"
"Rayko_Spline_Mask.py"
"Rayko_Styles_Loader.py"
- "Rayko_Text_Overlay.py"
+ "Rayko_Text_Overlay_Pro.py"
"settings_manager_api.py"
]
}
diff --git a/pyproject.toml b/pyproject.toml
index 00b8564..d687211 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI_RaykoStudio"
description = "Set of custom nodes for ComfyUI providing additional image processing capabilities"
-version = "0.25.2"
+version = "0.26.0"
license = { file = "LICENSE" }
dependencies = ["torch>=1.7.0", "numpy>=1.19.0", "Pillow>=8.0.0", "freetype-py>=2.5.1"]
diff --git a/requirements.txt b/requirements.txt
index c552d74..35a4999 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,7 @@
-torch>=1.7.0
-numpy>=1.19.0
-Pillow>=8.0.0
-freetype-py>=2.5.1
+torch
+opencv-python
+numpy
+Pillow>=10.0.0
+freetype-py>=2.5.1
+pycairo>=1.29.0
+scipy>=1.10.0
diff --git a/web/rs_overlay_pro.js b/web/rs_overlay_pro.js
new file mode 100644
index 0000000..b941175
--- /dev/null
+++ b/web/rs_overlay_pro.js
@@ -0,0 +1,2567 @@
+import { app } from "../../../scripts/app.js";
+import { api } from "../../../scripts/api.js";
+
+let activeNodeId = null;
+
+const HEAVY_PARAMS = ['text', 'font_name', 'outline_thickness', 'effect_mode', 'text_scale_x', 'text_scale_y', 'text_align', 'line_spacing', 'letter_spacing', 'enable_glow', 'glow_color', 'glow_size', 'glow_spread', 'glow_opacity'];
+const GLOW_PARAMS = ['enable_glow', 'glow_color', 'glow_size', 'glow_spread', 'glow_opacity'];
+const SHADOW_PARAMS = ['enable_shadow', 'shadow_color', 'shadow_offset_x', 'shadow_offset_y', 'shadow_opacity', 'shadow_blur'];
+
+// Дефолтные значения для эффектов
+const DEFAULT_EFFECTS = {
+ outline: {
+ outline_thickness: 0,
+ outline_color: "#808080",
+ outline_opacity: 1.0
+ },
+ glow: {
+ enable_glow: false,
+ glow_color: "#FFFFFF",
+ glow_size: 100,
+ glow_spread: 150,
+ glow_opacity: 1.0
+ },
+ shadow: {
+ enable_shadow: false,
+ shadow_color: "#333333",
+ shadow_offset_x: 10,
+ shadow_offset_y: 10,
+ shadow_opacity: 0.8,
+ shadow_blur: 15
+ }
+};
+
+app.registerExtension({
+ name: "RaykoStudio.RSOverlayPro",
+ async beforeRegisterNodeDef(nodeType, nodeData) {
+ if (nodeData.name !== "RS_OverlayPro") return;
+
+ function createColorPickerRow(node, labelText, paramName, defaultColor) {
+ const container = document.createElement('div');
+ container.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;';
+ const label = document.createElement('div');
+ label.textContent = labelText;
+ label.style.cssText = 'flex:6;font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:0.5px;';
+ const hexInput = document.createElement('input');
+ hexInput.type = 'text';
+ hexInput.value = defaultColor;
+ hexInput.style.cssText = 'flex:3.5;padding:4px 6px;background:#1a1a1a;color:#fff;border:1px solid #444;border-radius:4px;font-family:monospace;font-size:11px;outline:none;transition:0.15s;';
+ hexInput.onmouseenter = () => { hexInput.style.borderColor = '#666'; };
+ hexInput.onmouseleave = () => { if (document.activeElement !== hexInput) hexInput.style.borderColor = '#444'; };
+ hexInput.onfocus = () => { hexInput.style.borderColor = '#4CAF50'; };
+ hexInput.onblur = () => { hexInput.style.borderColor = '#444'; validateAndUpdate(); };
+ hexInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateAndUpdate(); hexInput.blur(); } };
+ const colorBox = document.createElement('div');
+ colorBox.style.cssText = 'flex:0 0 24px;width:24px;height:24px;background:' + defaultColor + ';border:1px solid #444;border-radius:4px;position:relative;cursor:pointer;transition:0.15s;';
+ colorBox.onmouseenter = () => { colorBox.style.borderColor = '#888'; };
+ colorBox.onmouseleave = () => { colorBox.style.borderColor = '#444'; };
+ const nativePicker = document.createElement('input');
+ nativePicker.type = 'color';
+ nativePicker.value = defaultColor;
+ nativePicker.style.cssText = 'opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer;';
+ nativePicker.oninput = (e) => {
+ const color = e.target.value.toUpperCase();
+ hexInput.value = color;
+ colorBox.style.background = color;
+ node.textParams[paramName] = color;
+ if (typeof node.scheduleRender === 'function') node.scheduleRender(paramName);
+ node.syncAllWidgets();
+ };
+ colorBox.appendChild(nativePicker);
+ function validateAndUpdate() {
+ let value = hexInput.value.trim();
+ if (!value.startsWith('#')) value = '#' + value;
+ const hexRegex = /^#([0-9A-Fa-f]{3}){1,2}$/;
+ if (hexRegex.test(value)) {
+ if (value.length === 4) value = '#' + value[1] + value[1] + value[2] + value[2] + value[3] + value[3];
+ value = value.toUpperCase();
+ hexInput.value = value;
+ colorBox.style.background = value;
+ nativePicker.value = value;
+ node.textParams[paramName] = value;
+ if (typeof node.scheduleRender === 'function') node.scheduleRender(paramName);
+ node.syncAllWidgets();
+ } else {
+ hexInput.value = node.textParams[paramName] || defaultColor;
+ }
+ }
+ container.appendChild(label);
+ container.appendChild(hexInput);
+ container.appendChild(colorBox);
+ if (!node.colorHexInputs) node.colorHexInputs = {};
+ if (!node.colorBoxes) node.colorBoxes = {};
+ node.colorHexInputs[paramName] = hexInput;
+ node.colorBoxes[paramName] = colorBox;
+ return container;
+ }
+
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function() {
+ if (onNodeCreated) onNodeCreated.apply(this, arguments);
+
+ this.overlay = { x: 0, y: 0, width: 100, height: 100, rotation: 0 };
+ this.overlayRelative = { x: 0.5, y: 0.5, width: 0.3, height: 0.3, rotation: 0 };
+ this.realOverlay = { width: 0, height: 0 };
+ this.realBackground = { width: 0, height: 0 };
+ this.displayWidth = 420;
+ this.displayHeight = 420;
+ this.canvasPixelSize = 420;
+ this.viewScale = 1.0;
+ this.viewOffsetX = 0;
+ this.viewOffsetY = 0;
+ this.backgroundImage = null;
+ this.isEditing = false;
+ this.isLoading = false;
+ this.dragType = null;
+ this.dragState = null;
+ this.currentSessionTimestamp = null;
+ this.isDragging = false;
+ this.awaitingServerRender = false;
+ this.debug = false;
+ this.textMask = null;
+ this.outlineMask = null;
+ this._offCanvas = null;
+ this._offCtx = null;
+ this.baseWidth = 0;
+ this.baseHeight = 0;
+ this._glowMask = null;
+ this._textMaskFile = null;
+ this._glowMaskTimeout = null;
+ this._glowMaskTimestamp = null;
+ this._glowExtraPadding = 0;
+ this.glowPaddingPreview = 0;
+ this._glowMaskAbortController = null;
+ this._glowKey = null;
+ this._glowDrawLogged = false;
+ this._glowFallbackLogged = false;
+ this._shadowMask = null;
+ this._shadowMaskTimeout = null;
+ this._shadowMaskTimestamp = null;
+ this._shadowMaskAbortController = null;
+ this._shadowKey = null;
+ this.colorHexInputs = {};
+ this.colorBoxes = {};
+ this._updatingWidgets = false;
+ this._sectionStates = { text: true, outline: false, glow: false, shadow: false };
+
+ this.textParams = {
+ text: "",
+ font_name: "",
+ text_color: "#FFFFFF",
+ outline_thickness: 0,
+ outline_color: "#808080",
+ text_opacity: 1.0,
+ outline_opacity: 1.0,
+ effect_mode: "on",
+ text_scale_x: 1.0,
+ text_scale_y: 1.0,
+ min_font_size: 4,
+ max_font_size: 2000,
+ padding: 5,
+ padding_y: 10,
+ text_align: "center",
+ line_spacing: 1.0,
+ letter_spacing: 0.0,
+ enable_glow: false,
+ glow_color: "#FFFFFF",
+ glow_size: 100,
+ glow_spread: 150,
+ glow_opacity: 1.0,
+ enable_shadow: false,
+ shadow_color: "#333333",
+ shadow_offset_x: 10,
+ shadow_offset_y: 10,
+ shadow_opacity: 0.8,
+ shadow_blur: 15
+ };
+
+ this.fontList = [];
+ this.advancedMode = false;
+ this.fontMenu = null;
+
+ (async () => {
+ try {
+ const response = await api.fetchApi("/rayko/rs_overlay_pro/get_fonts");
+ const data = await response.json();
+ if (data.font_list && data.font_list.length > 0) {
+ this.fontList = data.font_list;
+ if (!this.textParams.font_name || this.textParams.font_name === "") {
+ this.textParams.font_name = data.default_font || this.fontList[0];
+ }
+ if (this.overlayInputs && this.overlayInputs.font_name) {
+ this.overlayInputs.font_name.innerHTML = '';
+ this.fontList.forEach(f => {
+ const op = document.createElement('option');
+ op.value = f;
+ op.textContent = f;
+ if (f === this.textParams.font_name) op.selected = true;
+ this.overlayInputs.font_name.appendChild(op);
+ });
+ }
+ this.setDirtyCanvas(true);
+ }
+ } catch(e) {
+ console.error("Failed to load font list:", e);
+ }
+ })();
+
+ this.overlayContainer = null;
+ this.overlayCanvas = null;
+ this.overlayCtx = null;
+ this.overlayRenderLoop = null;
+ this.overlayInputs = {};
+ this.sliderDisplays = {};
+ this.currentRenderAbortController = null;
+ this.canvasRealWidth = 0;
+ this.canvasRealHeight = 0;
+ this.minWidth = 500;
+ this.minHeight = 420;
+ this.setSize([this.minWidth, this.minHeight]);
+
+ this.btnApplyHover = false;
+ this.btnCancelHover = false;
+ this.textareaState = { isOpen: false, element: null };
+ this.textareaRect = null;
+ this.textareaHover = false;
+ this.lastClickCoords = null;
+ this.fontSelectRect = null;
+ this.hexRect = null;
+ this.colorBoxRect = null;
+ this.fontSelectHover = false;
+ this.hexHover = false;
+ this.colorBoxHover = false;
+ this.alignButtonsRects = [];
+ this.alignButtonsHover = [false, false, false];
+ this.lineSpacingRect = null;
+ this.lineSpacingHover = false;
+ this.lineSpacingDragging = false;
+ this.lineSpacingPopup = null;
+ this.lineSpacingValueRect = null;
+ this.lineSpacingValueHover = false;
+ this.lineSpacingTrackRect = null;
+ this.fontColorY = 0;
+ this.buttonsY = 0;
+ this.pendingEditorData = null;
+ this.renderTimeout = null;
+
+ this.syncAllWidgets = function() {
+ if (this._updatingWidgets) return;
+ this._updatingWidgets = true;
+ try {
+ if (this.overlayInputs.text) this.overlayInputs.text.value = this.textParams.text || '';
+ if (this.overlayInputs.font_name) this.overlayInputs.font_name.value = this.textParams.font_name || '';
+ for (const key in this.overlayInputs) {
+ const el = this.overlayInputs[key];
+ if (!el) continue;
+ if (el.type === 'range') {
+ el.value = this.textParams[key];
+ if (this.sliderDisplays[key]) {
+ const step = parseFloat(el.step) || 1;
+ this.sliderDisplays[key].textContent = step < 1 ? parseFloat(this.textParams[key]).toFixed(2) : String(parseInt(this.textParams[key]));
+ }
+ } else if (el.type === 'checkbox') {
+ el.checked = !!this.textParams[key];
+ }
+ }
+ if (this.alignButtons) {
+ const align = this.textParams.text_align || 'center';
+ this.alignButtons.forEach((btn, i) => {
+ const types = ['left', 'center', 'right'];
+ const isActive = types[i] === align;
+ if (isActive) {
+ btn.style.background = '#4CAF50';
+ btn.style.color = '#fff';
+ btn.style.borderColor = '#4CAF50';
+ } else {
+ btn.style.background = '#252525';
+ btn.style.color = '#aaa';
+ btn.style.borderColor = '#444';
+ }
+ });
+ }
+ if (this.colorHexInputs) {
+ for (const key in this.colorHexInputs) {
+ const hexInput = this.colorHexInputs[key];
+ const colorBox = this.colorBoxes[key];
+ if (hexInput && this.textParams[key] !== undefined) {
+ hexInput.value = this.textParams[key];
+ if (colorBox) colorBox.style.background = this.textParams[key];
+ }
+ }
+ }
+ } finally {
+ this._updatingWidgets = false;
+ }
+ };
+
+ if (!this.overlayContainer) {
+ this.overlayContainer = document.createElement('div');
+ this.overlayContainer.style.cssText = 'position:fixed;top:60px;left:0;right:0;bottom:0;background:rgba(10,10,10,0.96);z-index:999;display:none;flex-direction:row;align-items:stretch;font-family:system-ui,-apple-system,sans-serif;';
+
+ this.overlayCanvasWrapper = document.createElement('div');
+ this.overlayCanvasWrapper.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;';
+
+ this.overlayCanvas = document.createElement('canvas');
+ this.overlayCanvas.style.cssText = 'box-shadow:0 8px 32px rgba(0,0,0,0.7);cursor:crosshair;max-width:98%;max-height:98%;border-radius:8px;';
+ this.overlayCanvasWrapper.appendChild(this.overlayCanvas);
+ this.overlayCtx = this.overlayCanvas.getContext('2d');
+
+ this.sidePanel = document.createElement('div');
+ this.sidePanel.style.cssText = 'width:320px;background:#151515;border-left:1px solid #333;padding:12px;display:flex;flex-direction:column;gap:4px;box-sizing:border-box;overflow-y:auto;';
+
+ const buttonContainer = document.createElement('div');
+ buttonContainer.style.cssText = 'position:sticky;top:0;background:#151515;z-index:10;padding:0 0 8px 0;border-bottom:1px solid #333;margin-bottom:8px;';
+ this.sidePanel.appendChild(buttonContainer);
+
+ const makeLabel = (txt) => {
+ const l = document.createElement('label');
+ l.textContent = txt;
+ l.style.cssText = 'color:#aaa;font-size:11px;font-weight:600;margin-bottom:-4px;display:block;';
+ return l;
+ };
+
+ const makeSectionHeader = (label, effectName, isEnabled, onToggle, onExpand) => {
+ const header = document.createElement('div');
+ header.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;cursor:pointer;user-select:none;';
+
+ const chevron = document.createElement('span');
+ chevron.textContent = '▶';
+ chevron.style.cssText = 'color:#666;font-size:12px;transition:transform 200ms;flex-shrink:0;';
+
+ const lbl = document.createElement('label');
+ lbl.textContent = label;
+ lbl.style.cssText = `flex:1;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;transition:color 200ms;`;
+ lbl.style.color = isEnabled ? '#00FF00' : '#666';
+
+ header.appendChild(chevron);
+ header.appendChild(lbl);
+
+ if (effectName) {
+ const toggle = document.createElement('input');
+ toggle.type = 'checkbox';
+ toggle.checked = isEnabled;
+ toggle.style.cssText = 'width:40px;height:20px;cursor:pointer;accent-color:#4CAF50;';
+ toggle.addEventListener('change', (e) => {
+ e.stopPropagation();
+ onToggle(toggle.checked);
+ });
+ header.appendChild(toggle);
+ }
+
+ header.addEventListener('click', (e) => {
+ if (e.target.type !== 'checkbox') {
+ onExpand();
+ }
+ });
+
+ return { header, chevron, lbl };
+ };
+
+ const makeSelect = (opts, val) => {
+ const s = document.createElement('select');
+ opts.forEach(o => {
+ const op = document.createElement('option');
+ op.value = o;
+ op.textContent = o;
+ if (o === val) op.selected = true;
+ s.appendChild(op);
+ });
+ s.style.cssText = 'width:100%;background:#252525;color:#eee;border:1px solid #444;border-radius:4px;padding:6px;font-size:12px;outline:none;';
+ return s;
+ };
+
+ const makeTextarea = (value) => {
+ const t = document.createElement('textarea');
+ t.value = value;
+ t.style.cssText = 'width:100%;background:#252525;color:#eee;border:1px solid #444;border-radius:4px;padding:6px;font-size:12px;outline:none;resize:vertical;min-height:60px;';
+ return t;
+ };
+
+ const makeBtn = (txt, col, onClick) => {
+ const b = document.createElement('button');
+ b.textContent = txt;
+ b.style.cssText = `width:100%;padding:10px;background:#222;color:${col};border:1px solid ${col};border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;margin-top:4px;transition:0.15s;`;
+ b.onmouseenter = () => { b.style.background = '#2a2a2a'; b.style.transform = 'translateY(-1px)'; };
+ b.onmouseleave = () => { b.style.background = '#222'; b.style.transform = 'none'; };
+ b.onclick = (e) => { e.stopPropagation(); onClick(); };
+ return b;
+ };
+
+ const div = () => { const d = document.createElement('div'); d.style.cssText = 'height:1px;background:#333;margin:4px 0;'; return d; };
+
+ const formatValue = (val, step) => {
+ if (step < 1) return parseFloat(val).toFixed(2);
+ return String(parseInt(val));
+ };
+
+ const makeToggle = (label, key, currentValue) => {
+ const wrap = document.createElement('div');
+ wrap.style.cssText = 'display:flex;align-items:center;gap:8px;';
+ const lbl = document.createElement('label');
+ lbl.textContent = label;
+ lbl.style.cssText = 'flex:1;color:#aaa;font-size:11px;font-weight:600;';
+ const toggle = document.createElement('input');
+ toggle.type = 'checkbox';
+ toggle.checked = currentValue;
+ toggle.style.cssText = 'width:40px;height:20px;cursor:pointer;accent-color:#4CAF50;';
+ toggle.addEventListener('change', () => {
+ this.textParams[key] = toggle.checked;
+ scheduleRender(key);
+ this.syncAllWidgets();
+ });
+ wrap.appendChild(lbl);
+ wrap.appendChild(toggle);
+ this.overlayInputs[key] = toggle;
+ return wrap;
+ };
+
+ const scheduleRender = (key, immediate = false) => {
+ if (SHADOW_PARAMS.includes(key)) {
+ this.setDirtyCanvas(true);
+ this.syncAllWidgets();
+ this.requestShadowMask();
+ return;
+ }
+ const isHeavy = HEAVY_PARAMS.includes(key);
+ const isGlowParam = GLOW_PARAMS.includes(key);
+ if (isHeavy && !isGlowParam) {
+ if (this.currentRenderAbortController) {
+ this.currentRenderAbortController.abort();
+ this.currentRenderAbortController = null;
+ }
+ if (this.renderTimeout) clearTimeout(this.renderTimeout);
+ const delay = immediate ? 0 : 40;
+ this.renderTimeout = setTimeout(() => {
+ this.currentRenderAbortController = new AbortController();
+ const signal = this.currentRenderAbortController.signal;
+ this.renderMasks(signal);
+ }, delay);
+ } else if (isGlowParam) {
+ this.requestGlowMask();
+ } else {
+ this.setDirtyCanvas(true);
+ }
+ this.syncAllWidgets();
+ };
+ this.scheduleRender = scheduleRender;
+
+ const makeSlider = (label, key, min, max, step, isFloat = false) => {
+ const container = document.createElement('div');
+ container.style.cssText = 'display:flex;flex-direction:column;gap:4px;';
+ const lbl = document.createElement('label');
+ lbl.textContent = label;
+ lbl.style.cssText = 'color:#aaa;font-size:11px;font-weight:600;';
+ container.appendChild(lbl);
+ const row = document.createElement('div');
+ row.style.cssText = 'display:flex;align-items:center;gap:8px;';
+ const slider = document.createElement('input');
+ slider.type = 'range';
+ slider.min = min;
+ slider.max = max;
+ slider.step = step;
+ slider.value = this.textParams[key];
+ slider.style.cssText = 'flex:1;height:4px;background:#252525;border-radius:2px;outline:none;cursor:pointer;-webkit-appearance:none;';
+ const valueDisplay = document.createElement('div');
+ valueDisplay.textContent = formatValue(this.textParams[key], step);
+ valueDisplay.style.cssText = 'min-width:50px;text-align:center;background:#252525;color:#4CAF50;border:1px solid #444;border-radius:4px;padding:4px 8px;font-size:12px;cursor:pointer;font-weight:600;transition:0.15s;';
+ valueDisplay.onmouseenter = () => { valueDisplay.style.background = '#2a2a2a'; valueDisplay.style.borderColor = '#4CAF50'; };
+ valueDisplay.onmouseleave = () => { valueDisplay.style.background = '#252525'; valueDisplay.style.borderColor = '#444'; };
+ valueDisplay.onclick = (e) => {
+ e.stopPropagation();
+ const currentValue = this.textParams[key];
+ const popup = document.createElement('div');
+ popup.style.cssText = 'position:fixed;z-index:10003;background:#1a1a1a;border:1px solid #444;border-radius:6px;padding:8px 12px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;align-items:center;gap:8px;';
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.value = currentValue;
+ input.min = min;
+ input.max = max;
+ input.step = step;
+ input.style.cssText = 'width:100px;background:#222;color:#fff;border:1px solid #444;border-radius:4px;padding:6px 10px;font-size:12px;outline:none;';
+ const saveBtn = document.createElement('button');
+ saveBtn.textContent = 'OK';
+ saveBtn.style.cssText = 'background:#4CAF50;color:#fff;border:none;border-radius:4px;padding:6px 12px;font-size:12px;cursor:pointer;';
+ const doSave = () => {
+ let num = isFloat ? parseFloat(input.value) : parseInt(input.value);
+ if (isNaN(num)) num = currentValue;
+ num = Math.max(min, Math.min(max, num));
+ this.textParams[key] = num;
+ slider.value = num;
+ valueDisplay.textContent = formatValue(num, step);
+ popup.remove();
+ scheduleRender(key);
+ this.syncAllWidgets();
+ };
+ saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); doSave(); };
+ input.onkeydown = (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); doSave(); } };
+ popup.appendChild(input);
+ popup.appendChild(saveBtn);
+ document.body.appendChild(popup);
+ const popupWidth = popup.offsetWidth;
+ const popupHeight = popup.offsetHeight;
+ let leftPos = e.clientX - popupWidth - 8;
+ let topPos = e.clientY;
+ if (leftPos < 8) leftPos = e.clientX + 8;
+ if (topPos + popupHeight > window.innerHeight - 8) topPos = window.innerHeight - popupHeight - 8;
+ popup.style.left = leftPos + 'px';
+ popup.style.top = topPos + 'px';
+ setTimeout(() => { input.focus(); input.select(); }, 50);
+ setTimeout(() => {
+ const closeHandler = (ev) => {
+ if (!popup.contains(ev.target)) {
+ popup.remove();
+ document.removeEventListener('mousedown', closeHandler);
+ }
+ };
+ document.addEventListener('mousedown', closeHandler);
+ }, 100);
+ };
+ slider.oninput = () => {
+ const val = isFloat ? parseFloat(slider.value) : parseInt(slider.value);
+ this.textParams[key] = val;
+ valueDisplay.textContent = formatValue(val, step);
+ scheduleRender(key);
+ this.syncAllWidgets();
+ };
+ this.overlayInputs[key] = slider;
+ this.sliderDisplays[key] = valueDisplay;
+ row.appendChild(slider);
+ row.appendChild(valueDisplay);
+ container.appendChild(row);
+ return container;
+ };
+
+ const btnNormalMode = makeBtn("🟢 NORMAL MODE", "#2196F3", () => { this._toggleAdvancedMode(); });
+ buttonContainer.appendChild(btnNormalMode);
+ const btnApply = makeBtn("✔️ APPLY", "#4CAF50", () => { this.sendTransforms(); this._toggleAdvancedMode(); });
+ buttonContainer.appendChild(btnApply);
+ const btnCancel = makeBtn("❌ CANCEL", "#dc3545", () => { this.cancelEditing(); this._toggleAdvancedMode(); });
+ buttonContainer.appendChild(btnCancel);
+
+ // TEXT секция (всегда раскрыта, без чекбокса)
+ const textHeader = document.createElement('div');
+ textHeader.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;';
+ const textChevron = document.createElement('span');
+ textChevron.textContent = '▼';
+ textChevron.style.cssText = 'color:#666;font-size:12px;';
+ const textLabel = document.createElement('label');
+ textLabel.textContent = 'TEXT';
+ textLabel.style.cssText = 'flex:1;color:#aaa;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;';
+ textHeader.appendChild(textChevron);
+ textHeader.appendChild(textLabel);
+ this.sidePanel.appendChild(textHeader);
+
+ const textContent = document.createElement('div');
+ textContent.style.cssText = 'display:block;';
+
+ this.overlayInputs.text = makeTextarea(this.textParams.text);
+ this.overlayInputs.text.addEventListener('input', () => {
+ this.textParams.text = this.overlayInputs.text.value;
+ scheduleRender('text');
+ this.syncAllWidgets();
+ });
+ textContent.appendChild(this.overlayInputs.text);
+
+ const alignContainer = document.createElement('div');
+ alignContainer.style.cssText = 'display:flex;gap:8px;margin-top:8px;';
+ const alignTypes = ['left', 'center', 'right'];
+ const alignIcons = [
+ '',
+ '',
+ ''
+ ];
+ this.alignButtons = [];
+ alignTypes.forEach((type, i) => {
+ const btn = document.createElement('button');
+ btn.innerHTML = alignIcons[i];
+ btn.style.cssText = `flex:1;padding:4px;background:#252525;color:#aaa;border:1px solid #444;border-radius:4px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:0.15s;`;
+ btn.onmouseenter = () => { btn.style.background = '#2a2a2a'; btn.style.color = '#fff'; };
+ btn.onmouseleave = () => { if (this.textParams.text_align !== type) { btn.style.background = '#252525'; btn.style.color = '#aaa'; } };
+ btn.onclick = (e) => {
+ e.stopPropagation();
+ this.textParams.text_align = type;
+ this.alignButtons.forEach((b, j) => {
+ if (j === i) {
+ b.style.background = '#4CAF50';
+ b.style.color = '#fff';
+ b.style.borderColor = '#4CAF50';
+ } else {
+ b.style.background = '#252525';
+ b.style.color = '#aaa';
+ b.style.borderColor = '#444';
+ }
+ });
+ scheduleRender('text_align');
+ this.syncAllWidgets();
+ };
+ if (this.textParams.text_align === type) {
+ btn.style.background = '#4CAF50';
+ btn.style.color = '#fff';
+ btn.style.borderColor = '#4CAF50';
+ }
+ this.alignButtons.push(btn);
+ alignContainer.appendChild(btn);
+ });
+ textContent.appendChild(alignContainer);
+ textContent.appendChild(div());
+
+ this.overlayInputs.font_name = makeSelect([], "");
+ this.overlayInputs.font_name.addEventListener('change', () => {
+ this.textParams.font_name = this.overlayInputs.font_name.value;
+ scheduleRender('font_name');
+ this.syncAllWidgets();
+ });
+ textContent.appendChild(this.overlayInputs.font_name);
+ textContent.appendChild(div());
+
+ textContent.appendChild(createColorPickerRow(this, "TEXT COLOR", "text_color", this.textParams.text_color));
+ textContent.appendChild(div());
+ textContent.appendChild(makeSlider("TEXT OPACITY", "text_opacity", 0, 1, 0.05, true));
+ textContent.appendChild(makeSlider("LINE SPACING", "line_spacing", 0.5, 3, 0.1, true));
+ textContent.appendChild(makeSlider("LETTER SPACING", "letter_spacing", -20, 100, 0.5, true));
+ textContent.appendChild(div());
+
+ this.sidePanel.appendChild(textContent);
+
+ // OUTLINE секция (без чекбокса, включается через thickness)
+ const outlineHeader = document.createElement('div');
+ outlineHeader.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;cursor:pointer;user-select:none;';
+ const outlineChevron = document.createElement('span');
+ outlineChevron.textContent = this._sectionStates.outline ? '▼' : '▶';
+ outlineChevron.style.cssText = 'color:#666;font-size:12px;transition:transform 200ms;flex-shrink:0;';
+ const outlineLabel = document.createElement('label');
+ outlineLabel.textContent = 'OUTLINE SETTINGS';
+ outlineLabel.style.cssText = `flex:1;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;transition:color 200ms;`;
+ outlineLabel.style.color = this.textParams.outline_thickness > 0 ? '#00FF00' : '#666';
+ outlineHeader.appendChild(outlineChevron);
+ outlineHeader.appendChild(outlineLabel);
+ outlineHeader.addEventListener('click', () => {
+ this._sectionStates.outline = !this._sectionStates.outline;
+ outlineChevron.textContent = this._sectionStates.outline ? '▼' : '▶';
+ outlineContent.style.display = this._sectionStates.outline ? 'block' : 'none';
+ });
+ this.sidePanel.appendChild(outlineHeader);
+
+ const outlineContent = document.createElement('div');
+ outlineContent.style.cssText = this._sectionStates.outline ? 'display:block;' : 'display:none;';
+ outlineContent.appendChild(div());
+ outlineContent.appendChild(makeSlider("THICKNESS", "outline_thickness", 0, 50, 1, false));
+ outlineContent.appendChild(createColorPickerRow(this, "COLOR", "outline_color", this.textParams.outline_color));
+ outlineContent.appendChild(div());
+ outlineContent.appendChild(makeSlider("OPACITY", "outline_opacity", 0, 1, 0.05, true));
+ outlineContent.appendChild(div());
+ this.sidePanel.appendChild(outlineContent);
+
+ // GLOW секция (с чекбоксом)
+ const glowHeader = document.createElement('div');
+ glowHeader.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;cursor:pointer;user-select:none;';
+ const glowChevron = document.createElement('span');
+ glowChevron.textContent = this._sectionStates.glow ? '▼' : '▶';
+ glowChevron.style.cssText = 'color:#666;font-size:12px;transition:transform 200ms;flex-shrink:0;';
+ const glowLabel = document.createElement('label');
+ glowLabel.textContent = 'GLOW SETTINGS';
+ glowLabel.style.cssText = `flex:1;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;transition:color 200ms;`;
+ glowLabel.style.color = this.textParams.enable_glow ? '#00FF00' : '#666';
+ glowHeader.appendChild(glowChevron);
+ glowHeader.appendChild(glowLabel);
+
+ const glowToggle = document.createElement('input');
+ glowToggle.type = 'checkbox';
+ glowToggle.checked = this.textParams.enable_glow;
+ glowToggle.style.cssText = 'width:40px;height:20px;cursor:pointer;accent-color:#4CAF50;';
+ glowToggle.addEventListener('click', (e) => e.stopPropagation());
+ glowToggle.addEventListener('change', (e) => {
+ e.stopPropagation();
+ const enabled = e.target.checked;
+ this.textParams.enable_glow = enabled;
+
+ if (!enabled) {
+ // Сброс в дефолт
+ this.textParams.glow_color = DEFAULT_EFFECTS.glow.glow_color;
+ this.textParams.glow_size = DEFAULT_EFFECTS.glow.glow_size;
+ this.textParams.glow_spread = DEFAULT_EFFECTS.glow.glow_spread;
+ this.textParams.glow_opacity = DEFAULT_EFFECTS.glow.glow_opacity;
+ }
+
+ glowLabel.style.color = enabled ? '#00FF00' : '#666';
+ this.syncAllWidgets();
+ scheduleRender('enable_glow');
+ });
+ glowHeader.appendChild(glowToggle);
+
+ glowHeader.addEventListener('click', (e) => {
+ if (e.target.type !== 'checkbox') {
+ this._sectionStates.glow = !this._sectionStates.glow;
+ glowChevron.textContent = this._sectionStates.glow ? '▼' : '▶';
+ glowContent.style.display = this._sectionStates.glow ? 'block' : 'none';
+ }
+ });
+ this.sidePanel.appendChild(glowHeader);
+
+ const glowContent = document.createElement('div');
+ glowContent.style.cssText = this._sectionStates.glow ? 'display:block;' : 'display:none;';
+ glowContent.appendChild(div());
+ glowContent.appendChild(createColorPickerRow(this, "GLOW COLOR", "glow_color", this.textParams.glow_color));
+ glowContent.appendChild(div());
+ glowContent.appendChild(makeSlider("SIZE", "glow_size", 0, 200, 1, false));
+ glowContent.appendChild(makeSlider("SPREAD", "glow_spread", 0, 300, 1, false));
+ glowContent.appendChild(makeSlider("OPACITY", "glow_opacity", 0, 1, 0.02, true));
+ glowContent.appendChild(div());
+ this.sidePanel.appendChild(glowContent);
+
+ // SHADOW секция (с чекбоксом)
+ const shadowHeader = document.createElement('div');
+ shadowHeader.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;cursor:pointer;user-select:none;';
+ const shadowChevron = document.createElement('span');
+ shadowChevron.textContent = this._sectionStates.shadow ? '▼' : '▶';
+ shadowChevron.style.cssText = 'color:#666;font-size:12px;transition:transform 200ms;flex-shrink:0;';
+ const shadowLabel = document.createElement('label');
+ shadowLabel.textContent = 'SHADOW SETTINGS';
+ shadowLabel.style.cssText = `flex:1;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;transition:color 200ms;`;
+ shadowLabel.style.color = this.textParams.enable_shadow ? '#00FF00' : '#666';
+ shadowHeader.appendChild(shadowChevron);
+ shadowHeader.appendChild(shadowLabel);
+
+ const shadowToggle = document.createElement('input');
+ shadowToggle.type = 'checkbox';
+ shadowToggle.checked = this.textParams.enable_shadow;
+ shadowToggle.style.cssText = 'width:40px;height:20px;cursor:pointer;accent-color:#4CAF50;';
+ shadowToggle.addEventListener('click', (e) => e.stopPropagation());
+ shadowToggle.addEventListener('change', (e) => {
+ e.stopPropagation();
+ const enabled = e.target.checked;
+ this.textParams.enable_shadow = enabled;
+
+ if (!enabled) {
+ // Сброс в дефолт
+ this.textParams.shadow_color = DEFAULT_EFFECTS.shadow.shadow_color;
+ this.textParams.shadow_offset_x = DEFAULT_EFFECTS.shadow.shadow_offset_x;
+ this.textParams.shadow_offset_y = DEFAULT_EFFECTS.shadow.shadow_offset_y;
+ this.textParams.shadow_opacity = DEFAULT_EFFECTS.shadow.shadow_opacity;
+ this.textParams.shadow_blur = DEFAULT_EFFECTS.shadow.shadow_blur;
+ }
+
+ shadowLabel.style.color = enabled ? '#00FF00' : '#666';
+ this.syncAllWidgets();
+ scheduleRender('enable_shadow');
+ });
+ shadowHeader.appendChild(shadowToggle);
+
+ shadowHeader.addEventListener('click', (e) => {
+ if (e.target.type !== 'checkbox') {
+ this._sectionStates.shadow = !this._sectionStates.shadow;
+ shadowChevron.textContent = this._sectionStates.shadow ? '▼' : '▶';
+ shadowContent.style.display = this._sectionStates.shadow ? 'block' : 'none';
+ }
+ });
+ this.sidePanel.appendChild(shadowHeader);
+
+ const shadowContent = document.createElement('div');
+ shadowContent.style.cssText = this._sectionStates.shadow ? 'display:block;' : 'display:none;';
+ shadowContent.appendChild(div());
+ shadowContent.appendChild(createColorPickerRow(this, "COLOR", "shadow_color", this.textParams. shadow_color));
+ shadowContent.appendChild(div());
+ shadowContent.appendChild(makeSlider("OFFSET X", "shadow_offset_x", -30, 30, 1, false));
+ shadowContent.appendChild(makeSlider("OFFSET Y", "shadow_offset_y", -30, 30, 1, false));
+ shadowContent.appendChild(makeSlider("BLUR", "shadow_blur", 0, 100, 1, false));
+ shadowContent.appendChild(makeSlider("OPACITY", "shadow_opacity", 0, 1, 0.05, true));
+ shadowContent.appendChild(div());
+ this.sidePanel.appendChild(shadowContent);
+
+ this.overlayContainer.appendChild(this.overlayCanvasWrapper);
+ this.overlayContainer.appendChild(this.sidePanel);
+ document.body.appendChild(this.overlayContainer);
+
+ this.syncAllWidgets();
+ }
+
+ api.addEventListener("rs-overlay-pro-start", (event) => {
+ if (event.detail.id != this.id) return;
+ this.pendingEditorData = event.detail;
+ this.openDeferredEditor();
+ });
+
+ api.addEventListener("interrupted", () => {
+ if (this.currentRenderAbortController) {
+ this.currentRenderAbortController.abort();
+ this.currentRenderAbortController = null;
+ }
+ if (this._glowMaskTimeout) {
+ clearTimeout(this._glowMaskTimeout);
+ this._glowMaskTimeout = null;
+ }
+ if (this._glowMaskAbortController) {
+ this._glowMaskAbortController.abort();
+ this._glowMaskAbortController = null;
+ }
+ if (this._shadowMaskTimeout) {
+ clearTimeout(this._shadowMaskTimeout);
+ this._shadowMaskTimeout = null;
+ }
+ if (this._shadowMaskAbortController) {
+ this._shadowMaskAbortController.abort();
+ this._shadowMaskAbortController = null;
+ }
+ this.pendingEditorData = null;
+ this.isLoading = false;
+ this.isEditing = false;
+ this.dragType = null;
+ this.dragState = null;
+ this.awaitingServerRender = false;
+ this._glowMask = null;
+ this._textMaskFile = null;
+ this._glowKey = null;
+ this._shadowMask = null;
+ this._shadowKey = null;
+ this._sectionStates = { text: true, outline: false, glow: false, shadow: false };
+ this.setDirtyCanvas(true);
+ this.closeTextarea();
+ });
+ };
+
+ nodeType.prototype.requestGlowMask = function() {
+ if (!this.textParams.enable_glow) {
+ this._glowMask = null;
+ this._glowExtraPadding = 0;
+ this.glowPaddingPreview = 0;
+ this._glowKey = null;
+ this.setDirtyCanvas(true);
+ return;
+ }
+ if (!this.textMask || !this._textMaskFile) return;
+ const glowSize = this.textParams.glow_size;
+ const glowSpread = this.textParams.glow_spread;
+ if (glowSize <= 0) {
+ this._glowMask = null;
+ this._glowExtraPadding = 0;
+ this.glowPaddingPreview = 0;
+ this._glowKey = null;
+ this.setDirtyCanvas(true);
+ return;
+ }
+ const glowKey = `${this._textMaskFile}_${glowSize}_${glowSpread}_${this.textParams.glow_opacity}_${this.textParams.glow_color}`;
+ if (this._glowKey === glowKey && this._glowMask) return;
+ this._glowKey = glowKey;
+ const glowPadding = glowSize * (this.displayWidth / this.canvasRealWidth);
+ this.glowPaddingPreview = glowPadding;
+ clearTimeout(this._glowMaskTimeout);
+ this._glowMaskTimeout = setTimeout(() => {
+ this.fetchGlowMaskFromServer();
+ }, 500);
+ };
+
+ nodeType.prototype.fetchGlowMaskFromServer = async function() {
+ try {
+ if (this._glowMaskAbortController) this._glowMaskAbortController.abort();
+ this._glowMaskAbortController = new AbortController();
+ const payload = {
+ text_mask_file: this._textMaskFile,
+ glow_size: this.textParams.glow_size,
+ glow_spread: this.textParams.glow_spread,
+ glow_opacity: this.textParams.glow_opacity,
+ node_id: String(this.id)
+ };
+ const response = await api.fetchApi('/rayko/rs_overlay_pro/render_glow_mask', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ signal: this._glowMaskAbortController.signal
+ });
+ const data = await response.json();
+ if (data.glow_file) {
+ const img = new Image();
+ img.crossOrigin = "Anonymous";
+ const ts = data.timestamp;
+ this._glowMaskTimestamp = ts;
+ this._glowExtraPadding = data.extra_padding || 0;
+ img.onload = () => {
+ if (this._glowMaskTimestamp === ts) {
+ this._glowMask = img;
+ this._glowDrawLogged = false;
+ this._glowFallbackLogged = false;
+ this.setDirtyCanvas(true);
+ }
+ };
+ img.onerror = () => {};
+ img.src = `/view?filename=${data.glow_file}&type=temp&t=${ts}`;
+ }
+ } catch (e) {
+ if (e.name === 'AbortError') return;
+ }
+ };
+
+ nodeType.prototype.requestShadowMask = function() {
+ if (!this.textParams.enable_shadow) {
+ this._shadowMask = null;
+ this._shadowKey = null;
+ this.setDirtyCanvas(true);
+ return;
+ }
+ if (!this.textMask || !this._textMaskFile) return;
+ const shadowBlur = this.textParams.shadow_blur || 0;
+ if (shadowBlur <= 0) {
+ this._shadowMask = null;
+ this._shadowKey = null;
+ this.setDirtyCanvas(true);
+ return;
+ }
+ const shadowKey = `${this._textMaskFile}_${shadowBlur}`;
+ if (this._shadowKey === shadowKey && this._shadowMask) return;
+ this._shadowKey = shadowKey;
+ clearTimeout(this._shadowMaskTimeout);
+ this._shadowMaskTimeout = setTimeout(() => {
+ this.fetchShadowMaskFromServer();
+ }, 500);
+ };
+
+ nodeType.prototype.fetchShadowMaskFromServer = async function() {
+ try {
+ if (this._shadowMaskAbortController) this._shadowMaskAbortController.abort();
+ this._shadowMaskAbortController = new AbortController();
+ const payload = {
+ text_mask_file: this._textMaskFile,
+ shadow_blur: this.textParams.shadow_blur || 0,
+ node_id: String(this.id)
+ };
+ const response = await api.fetchApi('/rayko/rs_overlay_pro/render_shadow_mask', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ signal: this._shadowMaskAbortController.signal
+ });
+ const data = await response.json();
+ if (data.shadow_file) {
+ const img = new Image();
+ img.crossOrigin = "Anonymous";
+ const ts = data.timestamp;
+ this._shadowMaskTimestamp = ts;
+ img.onload = () => {
+ if (this._shadowMaskTimestamp === ts) {
+ this._shadowMask = img;
+ this.setDirtyCanvas(true);
+ }
+ };
+ img.onerror = () => {};
+ img.src = `/view?filename=${data.shadow_file}&type=temp&t=${ts}`;
+ }
+ } catch (e) {
+ if (e.name === 'AbortError') return;
+ }
+ };
+
+ nodeType.prototype.drawMasked = function(ctx, maskImg, x, y, w, h, color, opacity) {
+ if (!maskImg) return;
+ if (!this._offCanvas) {
+ this._offCanvas = document.createElement('canvas');
+ this._offCtx = this._offCanvas.getContext('2d');
+ }
+ const off = this._offCanvas;
+ const offCtx = this._offCtx;
+ const mw = maskImg.naturalWidth || maskImg.width || w;
+ const mh = maskImg.naturalHeight || maskImg.height || h;
+ if (off.width !== mw || off.height !== mh) {
+ off.width = mw;
+ off.height = mh;
+ }
+ offCtx.clearRect(0, 0, mw, mh);
+ offCtx.drawImage(maskImg, 0, 0, mw, mh);
+ offCtx.globalCompositeOperation = 'source-in';
+ offCtx.globalAlpha = Math.min(1, Math.max(0, opacity));
+ offCtx.fillStyle = color;
+ offCtx.fillRect(0, 0, mw, mh);
+ offCtx.globalCompositeOperation = 'source-over';
+ offCtx.globalAlpha = 1;
+ ctx.drawImage(off, x, y, w, h);
+ };
+
+ nodeType.prototype.drawGlowPreview = function(ctx, x, y, w, h) {
+ if (!this.textParams.enable_glow || !this.textMask) return;
+ const glowSize = this.textParams.glow_size;
+ if (glowSize <= 0) return;
+ const glowOpacity = this.textParams.glow_opacity;
+ const glowColor = this.textParams.glow_color;
+ if (this._glowMask) {
+ const padding = this._glowExtraPadding || 0;
+ if (!this._offCanvas) {
+ this._offCanvas = document.createElement('canvas');
+ this._offCtx = this._offCanvas.getContext('2d');
+ }
+ const off = this._offCanvas;
+ const offCtx = this._offCtx;
+ const mw = this._glowMask.naturalWidth;
+ const mh = this._glowMask.naturalHeight;
+ if (off.width !== mw || off.height !== mh) {
+ off.width = mw;
+ off.height = mh;
+ }
+ offCtx.clearRect(0, 0, mw, mh);
+ offCtx.drawImage(this._glowMask, 0, 0, mw, mh);
+ offCtx.globalCompositeOperation = 'source-in';
+ offCtx.globalAlpha = Math.min(1, Math.max(0, glowOpacity));
+ offCtx.fillStyle = glowColor;
+ offCtx.fillRect(0, 0, mw, mh);
+ offCtx.globalCompositeOperation = 'source-over';
+ offCtx.globalAlpha = 1;
+ const textW = mw - 2 * padding;
+ const textH = mh - 2 * padding;
+ const scaleX = w / textW;
+ const scaleY = h / textH;
+ const glowW = mw * scaleX;
+ const glowH = mh * scaleY;
+ const glowX = x - padding * scaleX;
+ const glowY = y - padding * scaleY;
+ ctx.drawImage(off, glowX, glowY, glowW, glowH);
+ return;
+ }
+ const maxBlur = Math.min(250, glowSize);
+ ctx.save();
+ ctx.shadowColor = glowColor;
+ ctx.shadowBlur = maxBlur;
+ ctx.shadowOffsetX = 0;
+ ctx.shadowOffsetY = 0;
+ this.drawMasked(ctx, this.textMask, x, y, w, h, glowColor, glowOpacity);
+ ctx.restore();
+ };
+
+ nodeType.prototype.drawShadow = function(ctx, x, y, w, h) {
+ if (!this.textParams.enable_shadow || !this.textMask) return;
+ const opacity = this.textParams.shadow_opacity;
+ if (opacity === undefined || opacity === null || opacity <= 0) return;
+ const offsetX = this.textParams.shadow_offset_x || 0;
+ const offsetY = this.textParams.shadow_offset_y || 0;
+ const color = this.textParams.shadow_color || '#333333';
+ const blur = this.textParams.shadow_blur || 0;
+
+ if (this._shadowMask && blur > 0) {
+ const mw = this._shadowMask.naturalWidth;
+ const mh = this._shadowMask.naturalHeight;
+ if (!this._offCanvas) {
+ this._offCanvas = document.createElement('canvas');
+ this._offCtx = this._offCanvas.getContext('2d');
+ }
+ const off = this._offCanvas;
+ const offCtx = this._offCtx;
+ if (off.width !== mw || off.height !== mh) {
+ off.width = mw;
+ off.height = mh;
+ }
+ offCtx.clearRect(0, 0, mw, mh);
+ offCtx.drawImage(this._shadowMask, 0, 0, mw, mh);
+ offCtx.globalCompositeOperation = 'source-in';
+ offCtx.globalAlpha = Math.min(1, Math.max(0, opacity));
+ offCtx.fillStyle = color;
+ offCtx.fillRect(0, 0, mw, mh);
+ offCtx.globalCompositeOperation = 'source-over';
+ offCtx.globalAlpha = 1;
+ ctx.drawImage(off, x + offsetX, y + offsetY, w, h);
+ return;
+ }
+
+ ctx.save();
+ ctx.shadowColor = color;
+ ctx.shadowBlur = blur;
+ ctx.shadowOffsetX = offsetX;
+ ctx.shadowOffsetY = offsetY;
+ ctx.globalAlpha = opacity;
+ this.drawMasked(ctx, this.textMask, x, y, w, h, color, 1.0);
+ ctx.restore();
+ };
+
+ nodeType.prototype.drawTextOverlay = function(ctx, x, y, w, h) {
+ this.drawGlowPreview(ctx, x, y, w, h);
+ this.drawShadow(ctx, x, y, w, h);
+ if (this.textParams.effect_mode === "on" && this.outlineMask) {
+ this.drawMasked(ctx, this.outlineMask, x, y, w, h,
+ this.textParams.outline_color, this.textParams.outline_opacity);
+ }
+ if (this.textMask) {
+ this.drawMasked(ctx, this.textMask, x, y, w, h,
+ this.textParams.text_color, this.textParams.text_opacity);
+ }
+ };
+
+ nodeType.prototype._resizeOverlayCanvas = function() {
+ if (!this.overlayCanvasWrapper || !this.overlayCanvas) return;
+ const w = this.overlayCanvasWrapper.clientWidth;
+ const h = this.overlayCanvasWrapper.clientHeight;
+ if (w > 0 && h > 0) {
+ this.overlayCanvas.width = w;
+ this.overlayCanvas.height = h;
+ if (this.isEditing && this.backgroundImage) {
+ this.updateOverlayAbsolute();
+ this.computeAndApplyView();
+ }
+ this.setDirtyCanvas(true);
+ if (this.advancedMode && this.isEditing) {
+ this.computeAndApplyView();
+ }
+ }
+ };
+
+ nodeType.prototype._handleOverlayEvent = function(e, type) {
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = this.overlayCanvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const scaleX = this.overlayCanvas.width / rect.width;
+ const scaleY = this.overlayCanvas.height / rect.height;
+ const x = (e.clientX - rect.left) * scaleX;
+ const y = (e.clientY - rect.top) * scaleY;
+ if (type === 'down') this.onMouseDown(null, [x, y]);
+ else if (type === 'move') this.onMouseMove(null, [x, y]);
+ else this.onMouseUp();
+ this.setDirtyCanvas(true);
+ };
+
+ nodeType.prototype._handleOverlayWheel = function(e) {
+ if (!this.advancedMode || !this.isEditing || activeNodeId !== this.id) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const canvasRect = this.overlayCanvas.getBoundingClientRect();
+ if (canvasRect.width === 0 || canvasRect.height === 0) return;
+ const scaleX = this.overlayCanvas.width / canvasRect.width;
+ const scaleY = this.overlayCanvas.height / canvasRect.height;
+ const canvasX = (e.clientX - canvasRect.left) * scaleX;
+ const canvasY = (e.clientY - canvasRect.top) * scaleY;
+ const { rectX, rectY } = this.getCanvasMetrics();
+ const worldX = (canvasX - rectX - this.viewOffsetX) / this.viewScale;
+ const worldY = (canvasY - rectY - this.viewOffsetY) / this.viewScale;
+ const zoomFactor = e.deltaY < 0 ? 1.15 : 0.85;
+ this.viewScale = Math.max(0.05, Math.min(10.0, this.viewScale * zoomFactor));
+ this.viewOffsetX = canvasX - rectX - (worldX * this.viewScale);
+ this.viewOffsetY = canvasY - rectY - (worldY * this.viewScale);
+ this.setDirtyCanvas(true);
+ };
+
+ nodeType.prototype._toggleAdvancedMode = function(forceClose = false) {
+ if (this.currentRenderAbortController) {
+ this.currentRenderAbortController.abort();
+ this.currentRenderAbortController = null;
+ }
+ if (forceClose || this.advancedMode) {
+ this.updateRelativeFromAbsolute();
+ this.advancedMode = false;
+ if (activeNodeId === this.id) activeNodeId = null;
+ this.overlayContainer.style.display = 'none';
+ if (this._overlayWheelHandler) {
+ this.overlayCanvas.removeEventListener('wheel', this._overlayWheelHandler);
+ delete this._overlayWheelHandler;
+ }
+ if (this._overlayMouseHandler) {
+ this.overlayCanvas.removeEventListener('mousedown', this._overlayMouseHandler);
+ this.overlayCanvas.removeEventListener('mousemove', this._overlayMouseHandler);
+ this.overlayCanvas.removeEventListener('mouseup', this._overlayMouseHandler);
+ this.overlayCanvas.removeEventListener('mouseleave', this._overlayMouseLeaveHandler);
+ delete this._overlayMouseHandler;
+ delete this._overlayMouseLeaveHandler;
+ }
+ if (this._globalKeyHandler) window.removeEventListener('keydown', this._globalKeyHandler);
+ if (this.overlayRenderLoop) cancelAnimationFrame(this.overlayRenderLoop);
+ this.setDirtyCanvas(true);
+ return;
+ }
+ if (activeNodeId && activeNodeId !== this.id) {
+ const otherNode = app.graph?.nodes.find(n => n.id == activeNodeId);
+ if (otherNode && typeof otherNode._toggleAdvancedMode === 'function') {
+ otherNode._toggleAdvancedMode(true);
+ }
+ }
+ this.advancedMode = true;
+ activeNodeId = this.id;
+ this.overlayContainer.style.display = 'flex';
+ requestAnimationFrame(() => {
+ this._resizeOverlayCanvas();
+ this.computeAndApplyView();
+ });
+ this._overlayMouseHandler = (e) => this._handleOverlayEvent(e, e.type === 'mousedown' ? 'down' : e.type === 'mousemove' ? 'move' : 'up');
+ this._overlayMouseLeaveHandler = () => this.onMouseUp();
+ this.overlayCanvas.addEventListener('mousedown', this._overlayMouseHandler);
+ this.overlayCanvas.addEventListener('mousemove', this._overlayMouseHandler);
+ this.overlayCanvas.addEventListener('mouseup', this._overlayMouseHandler);
+ this.overlayCanvas.addEventListener('mouseleave', this._overlayMouseLeaveHandler);
+ this._overlayWheelHandler = (e) => this._handleOverlayWheel(e);
+ this.overlayCanvas.addEventListener('wheel', this._overlayWheelHandler, { passive: false });
+ this._globalKeyHandler = (e) => {
+ if (e.key === 'Escape' && activeNodeId === this.id) {
+ this._toggleAdvancedMode();
+ }
+ };
+ window.addEventListener('keydown', this._globalKeyHandler);
+ let lastDrawTime = 0;
+ this.overlayRenderLoop = () => {
+ if (!this.advancedMode || !this.overlayCtx) return;
+ const now = performance.now();
+ if (now - lastDrawTime < 50) {
+ requestAnimationFrame(this.overlayRenderLoop);
+ return;
+ }
+ lastDrawTime = now;
+ this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
+ this.drawOverlayCanvas(this.overlayCtx);
+ requestAnimationFrame(this.overlayRenderLoop);
+ };
+ requestAnimationFrame(this.overlayRenderLoop);
+ };
+
+ nodeType.prototype.drawOverlayCanvas = function(ctx) {
+ if (!this.isEditing || !this.backgroundImage) {
+ ctx.fillStyle = "#888";
+ ctx.font = "14px Arial";
+ ctx.textAlign = "center";
+ ctx.fillText("Loading...", this.overlayCanvas.width / 2, this.overlayCanvas.height / 2);
+ return;
+ }
+ const { rectX, rectY } = this.getCanvasMetrics();
+ const useScale = this.viewScale;
+ const useOffsetX = this.viewOffsetX;
+ const useOffsetY = this.viewOffsetY;
+ ctx.save();
+ ctx.translate(rectX + useOffsetX, rectY + useOffsetY);
+ ctx.scale(useScale, useScale);
+ ctx.drawImage(this.backgroundImage, -this.displayWidth / 2, -this.displayHeight / 2, this.displayWidth, this.displayHeight);
+ if (this.textMask) {
+ ctx.save();
+ ctx.translate(this.overlay.x, this.overlay.y);
+ ctx.rotate(this.overlay.rotation * Math.PI / 180);
+ const rectW = this.overlay.width;
+ const rectH = this.overlay.height;
+ this.drawTextOverlay(ctx, -rectW / 2, -rectH / 2, rectW, rectH);
+ if (this.advancedMode) {
+ ctx.shadowColor = "rgba(0,0,0,0.8)";
+ ctx.shadowBlur = 4 / useScale;
+ ctx.strokeStyle = "#00FF00";
+ ctx.lineWidth = 1 / useScale;
+ ctx.strokeRect(-rectW / 2, -rectH / 2, rectW, rectH);
+ ctx.shadowColor = "transparent";
+ ctx.shadowBlur = 0;
+ const hw = rectW / 2, hh = rectH / 2;
+ const hs = 6 / useScale;
+ ctx.fillStyle = "#FF0000";
+ [[hw, hh], [-hw, hh], [hw, -hh], [-hw, -hh]].forEach(([x, y]) => ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs));
+ [[hw, 0], [-hw, 0], [0, hh], [0, -hh]].forEach(([x, y]) => ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs));
+ const rotHandleY = -hh - 40;
+ ctx.beginPath();
+ ctx.arc(0, rotHandleY, 7 / useScale, 0, Math.PI * 2);
+ ctx.fillStyle = "#ff9800";
+ ctx.fill();
+ ctx.strokeStyle = "#fff";
+ ctx.lineWidth = 1 / useScale;
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+ ctx.restore();
+ if (this.advancedMode) {
+ ctx.fillStyle = "#ff9800";
+ ctx.font = "12px Arial";
+ ctx.textAlign = "left";
+ ctx.fillText(`EDITING (Scale: ${(useScale * 100).toFixed(0)}%)`, this.overlayCanvas.width - 200, this.overlayCanvas.height - 20);
+ }
+ };
+
+ nodeType.prototype.openDeferredEditor = function() {
+ if (!this.pendingEditorData) return;
+ const data = this.pendingEditorData;
+ this.textMask = null;
+ this.outlineMask = null;
+ this._glowMask = null;
+ this._textMaskFile = null;
+ this._glowMaskTimestamp = null;
+ this._glowExtraPadding = 0;
+ this._glowKey = null;
+ this._shadowMask = null;
+ this._shadowKey = null;
+ this.backgroundImage = null;
+ this.isLoading = true;
+ this.currentSessionTimestamp = data.timestamp;
+ this.textParams.text_scale_x = 1.0;
+ this.textParams.text_scale_y = 1.0;
+ this.awaitingServerRender = false;
+ if (this.fontList.length === 0 && data.font_list && data.font_list.length > 0) {
+ this.fontList = data.font_list;
+ if (!this.textParams.font_name) {
+ this.textParams.font_name = data.default_font || this.fontList[0];
+ }
+ }
+ this.realBackground = { width: data.bg_width, height: data.bg_height };
+ this.canvasRealWidth = this.realBackground.width;
+ this.canvasRealHeight = this.realBackground.height;
+ this.updateDisplaySize(this.canvasPixelSize);
+ const bgFile = data.bg_file, ts = data.timestamp;
+ const img = new Image();
+ img.crossOrigin = "Anonymous";
+ img.onload = () => {
+ this.backgroundImage = img;
+ this.isLoading = false;
+ const initialWidth = this.displayWidth;
+ const initialHeight = this.displayWidth * (3 / 5);
+ this.overlayRelative = {
+ width: initialWidth / this.displayWidth,
+ height: initialHeight / this.displayHeight,
+ x: 0.5,
+ y: 0.5,
+ rotation: 0
+ };
+ this.updateOverlayAbsolute();
+ this.computeAndApplyView();
+ this.isEditing = true;
+ this.baseWidth = this.overlay.width;
+ this.baseHeight = this.overlay.height;
+ this.setDirtyCanvas(true);
+ this.renderMasks();
+ this.syncAllWidgets();
+ };
+ img.onerror = () => {
+ this.isLoading = false;
+ };
+ img.src = `/view?filename=${bgFile}&type=temp&t=${ts}`;
+ };
+
+ nodeType.prototype.renderMasks = async function(signal = null) {
+ if (!this.isEditing || !this.backgroundImage) return;
+ const scale = this.realBackground.width / this.displayWidth;
+ const containerWidth = Math.round(this.baseWidth * scale);
+ const containerHeight = Math.round(this.baseHeight * scale);
+ if (containerWidth < 10 || containerHeight < 10) return;
+ try {
+ const response = await api.fetchApi("/rayko/rs_overlay_pro/render_masks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ node_id: String(this.id),
+ text_params: this.textParams,
+ container_width: containerWidth,
+ container_height: containerHeight
+ }),
+ signal: signal
+ });
+ const data = await response.json();
+ if (data.error) {
+ this.awaitingServerRender = false;
+ return;
+ }
+ let loadedCount = 0;
+ const totalMasks = 2;
+ const checkDone = () => {
+ if (loadedCount === totalMasks) {
+ this.awaitingServerRender = false;
+ this.setDirtyCanvas(true);
+ this.requestGlowMask();
+ this.requestShadowMask();
+ }
+ };
+ const textImg = new Image();
+ textImg.crossOrigin = "Anonymous";
+ textImg.onload = () => {
+ this.textMask = textImg;
+ this._textMaskFile = data.text_mask_file;
+ loadedCount++;
+ checkDone();
+ };
+ textImg.onerror = () => {
+ loadedCount++;
+ checkDone();
+ };
+ textImg.src = `/view?filename=${data.text_mask_file}&type=temp&t=${data.timestamp}`;
+
+ const outlineImg = new Image();
+ outlineImg.crossOrigin = "Anonymous";
+ outlineImg.onload = () => {
+ this.outlineMask = outlineImg;
+ loadedCount++;
+ checkDone();
+ };
+ outlineImg.onerror = () => {
+ loadedCount++;
+ checkDone();
+ };
+ outlineImg.src = `/view?filename=${data.outline_mask_file}&type=temp&t=${data.timestamp}`;
+ } catch (e) {
+ if (e.name === 'AbortError') return;
+ this.awaitingServerRender = false;
+ }
+ };
+
+ nodeType.prototype.updateOverlayAbsolute = function() {
+ this.overlay.x = (this.overlayRelative.x - 0.5) * this.displayWidth;
+ this.overlay.y = (this.overlayRelative.y - 0.5) * this.displayHeight;
+ this.overlay.width = this.overlayRelative.width * this.displayWidth;
+ this.overlay.height = this.overlayRelative.height * this.displayHeight;
+ this.overlay.rotation = this.overlayRelative.rotation;
+ };
+
+ nodeType.prototype.updateRelativeFromAbsolute = function() {
+ this.overlayRelative.x = (this.overlay.x / this.displayWidth) + 0.5;
+ this.overlayRelative.y = (this.overlay.y / this.displayHeight) + 0.5;
+ this.overlayRelative.width = this.overlay.width / this.displayWidth;
+ this.overlayRelative.height = this.overlay.height / this.displayHeight;
+ this.overlayRelative.rotation = this.overlay.rotation;
+ };
+
+ nodeType.prototype.computeAndApplyView = function() {
+ const bgW = this.displayWidth, bgH = this.displayHeight;
+ const ovW = this.overlay.width, ovH = this.overlay.height;
+ const ovL = this.overlay.x - ovW/2, ovT = this.overlay.y - ovH/2;
+ const ovR = this.overlay.x + ovW/2, ovB = this.overlay.y + ovH/2;
+ const bgL = -bgW/2, bgT = -bgH/2, bgR = bgW/2, bgB = bgH/2;
+ const minX = Math.min(ovL, bgL), minY = Math.min(ovT, bgT), maxX = Math.max(ovR, bgR), maxY = Math.max(ovB, bgB);
+ const contentW = Math.max(1, maxX - minX), contentH = Math.max(1, maxY - minY);
+ const contentCX = (minX + maxX)/2, contentCY = (minY + maxY)/2;
+ let glowPadding = this.glowPaddingPreview || 0;
+ if (this.advancedMode) {
+ const cw = this.overlayCanvas.width || (this.overlayCanvasWrapper ? this.overlayCanvasWrapper.clientWidth : 1000);
+ const ch = this.overlayCanvas.height || (this.overlayCanvasWrapper ? this.overlayCanvasWrapper.clientHeight : 800);
+ const availableW = cw * 0.8, availableH = ch * 0.8;
+ const totalW = contentW + glowPadding*2;
+ const totalH = contentH + glowPadding*2;
+ this.viewScale = Math.max(0.1, Math.min(3.0, Math.min(availableW/totalW, availableH/totalH)));
+ this.viewOffsetX = cw/2 - (contentCX * this.viewScale);
+ this.viewOffsetY = ch/2 - (contentCY * this.viewScale);
+ } else {
+ const availableW = this.canvasPixelSize * 0.95, availableH = this.canvasPixelSize * 0.95;
+ const totalW = contentW + glowPadding*2;
+ const totalH = contentH + glowPadding*2;
+ this.viewScale = Math.max(0.1, Math.min(3.0, Math.min(availableW/totalW, availableH/totalH)));
+ this.viewOffsetX = this.canvasPixelSize/2 - (contentCX * this.viewScale);
+ this.viewOffsetY = this.canvasPixelSize/2 - (contentCY * this.viewScale);
+ }
+ };
+
+ nodeType.prototype.updateDisplaySize = function(cS) {
+ this.canvasPixelSize = cS;
+ const safeHeight = this.realBackground.height || 1;
+ const bgAR = this.realBackground.width / safeHeight;
+ if (bgAR >= 1) {
+ this.displayWidth = cS;
+ this.displayHeight = cS / bgAR;
+ } else {
+ this.displayHeight = cS;
+ this.displayWidth = cS * bgAR;
+ }
+ };
+
+ nodeType.prototype.getRealTransform = function() {
+ const dS = this.canvasRealWidth / (this.displayWidth || 1);
+ const absX = (this.overlay.x * dS) + (this.canvasRealWidth / 2);
+ const absY = (this.overlay.y * dS) + (this.canvasRealHeight / 2);
+ const scaleX = this.overlay.width / this.displayWidth;
+ const scaleY = this.overlay.height / this.displayHeight;
+ const baseWidthPx = this.baseWidth * dS;
+ const baseHeightPx = this.baseHeight * dS;
+ return {
+ x: absX,
+ y: absY,
+ scale_x: scaleX,
+ scale_y: scaleY,
+ rotation: this.overlay.rotation,
+ base_width_px: baseWidthPx,
+ base_height_px: baseHeightPx
+ };
+ };
+
+ nodeType.prototype.computeScreenHandles = function(rectX, rectY, useScale, useOffsetX, useOffsetY) {
+ const hw = this.overlay.width / 2;
+ const hh = this.overlay.height / 2;
+ const rot = this.overlay.rotation * Math.PI / 180;
+ const cos = Math.cos(rot), sin = Math.sin(rot);
+ const handles = {
+ 'scale-tl': [-hw, -hh],
+ 'scale-tr': [hw, -hh],
+ 'scale-bl': [-hw, hh],
+ 'scale-br': [hw, hh],
+ 'scale-t': [0, -hh],
+ 'scale-b': [0, hh],
+ 'scale-l': [-hw, 0],
+ 'scale-r': [hw, 0],
+ 'rotate': [0, -hh - 40]
+ };
+ const screenHandles = {};
+ for (const [name, loc] of Object.entries(handles)) {
+ const rx = loc[0]*cos - loc[1]*sin, ry = loc[0]*sin + loc[1]*cos;
+ screenHandles[name] = { x: rectX + useOffsetX + (this.overlay.x + rx)*useScale, y: rectY + useOffsetY + (this.overlay.y + ry)*useScale };
+ }
+ return screenHandles;
+ };
+
+ nodeType.prototype.sendTransforms = async function() {
+ if (this.currentRenderAbortController) {
+ this.currentRenderAbortController.abort();
+ this.currentRenderAbortController = null;
+ }
+ const transform = this.getRealTransform();
+ const scale = this.realBackground.width / this.displayWidth;
+ const textParamsWithBase = {
+ ...this.textParams,
+ base_width_px: transform.base_width_px,
+ base_height_px: transform.base_height_px,
+ shadow_offset_x: Math.round(this.textParams.shadow_offset_x * scale),
+ shadow_offset_y: Math.round(this.textParams.shadow_offset_y * scale),
+ shadow_blur: Math.round((this.textParams.shadow_blur || 0) * scale)
+ };
+ const payload = {
+ id: String(this.id),
+ transforms: transform,
+ text_params: textParamsWithBase
+ };
+ try {
+ await api.fetchApi("/rayko/rs_overlay_pro", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload)
+ });
+ this.isEditing = false;
+ this.setDirtyCanvas(true);
+ } catch(e) {}
+ };
+
+ nodeType.prototype.cancelEditing = async function() {
+ if (this.currentRenderAbortController) {
+ this.currentRenderAbortController.abort();
+ this.currentRenderAbortController = null;
+ }
+ if (this._glowMaskTimeout) {
+ clearTimeout(this._glowMaskTimeout);
+ this._glowMaskTimeout = null;
+ }
+ if (this._glowMaskAbortController) {
+ this._glowMaskAbortController.abort();
+ this._glowMaskAbortController = null;
+ }
+ if (this._shadowMaskTimeout) {
+ clearTimeout(this._shadowMaskTimeout);
+ this._shadowMaskTimeout = null;
+ }
+ if (this._shadowMaskAbortController) {
+ this._shadowMaskAbortController.abort();
+ this._shadowMaskAbortController = null;
+ }
+ try { await api.interrupt(); } catch(e) {}
+ await fetch("/rayko/rs_overlay_pro/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(this.id) }) });
+ this.isEditing = false; this.isLoading = false; this.dragType = null; this.dragState = null; this.awaitingServerRender = false;
+ this._glowMask = null; this._textMaskFile = null; this._glowExtraPadding = 0; this._glowKey = null;
+ this._shadowMask = null; this._shadowKey = null;
+ this.setDirtyCanvas(true);
+ this.closeTextarea();
+ };
+
+ nodeType.prototype.roundRect = function(ctx, x, y, w, h, r) {
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.lineTo(x + w - r, y);
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
+ ctx.lineTo(x + w, y + h - r);
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
+ ctx.lineTo(x + r, y + h);
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
+ ctx.lineTo(x, y + r);
+ ctx.quadraticCurveTo(x, y, x + r, y);
+ ctx.closePath();
+ };
+
+ nodeType.prototype.getNodeScreenCoords = function(localX, localY) {
+ try {
+ const canvas = LGraphCanvas.active_canvas;
+ if (!canvas) return { x: 0, y: 0 };
+ const rect = canvas.canvas.getBoundingClientRect();
+ const ds = canvas.ds;
+ return {
+ x: rect.left + (this.pos[0] + localX) * ds.scale + ds.offset[0],
+ y: rect.top + (this.pos[1] + localY) * ds.scale + ds.offset[1]
+ };
+ } catch(e) {
+ return { x: 0, y: 0 };
+ }
+ };
+
+ nodeType.prototype.openTextarea = function(clickEvent) {
+ if (this.textareaState.isOpen && this.textareaState.element) {
+ this.textareaState.element.remove();
+ }
+ const currentValue = this.textParams.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 = 'SAVE';
+ 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);
+ if (clickEvent) {
+ popup.style.left = (clickEvent.clientX + 8) + 'px';
+ popup.style.top = clickEvent.clientY + 'px';
+ }
+ document.body.appendChild(popup);
+ setTimeout(() => {
+ input.focus();
+ setTimeout(() => {
+ if (currentValue && currentValue.length > 0) input.select();
+ }, 10);
+ }, 50);
+ saveBtn.onclick = (ev) => {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.textParams.text = input.value;
+ popup.remove();
+ this.textareaState.isOpen = false;
+ this.textareaState.element = null;
+ this.setDirtyCanvas(true);
+ if (this.scheduleRender) this.scheduleRender('text');
+ this.syncAllWidgets();
+ };
+ input.onkeydown = (ev) => {
+ if (ev.key === 'Enter' && ev.ctrlKey) {
+ ev.preventDefault();
+ this.textParams.text = input.value;
+ popup.remove();
+ this.textareaState.isOpen = false;
+ this.textareaState.element = null;
+ this.setDirtyCanvas(true);
+ if (this.scheduleRender) this.scheduleRender('text');
+ this.syncAllWidgets();
+ }
+ };
+ this.textareaState.isOpen = true;
+ this.textareaState.element = popup;
+ };
+
+ nodeType.prototype.closeTextarea = function() {
+ if (this.textareaState.element) {
+ this.textareaState.element.remove();
+ this.textareaState.isOpen = false;
+ this.textareaState.element = null;
+ }
+ };
+
+ nodeType.prototype.showFontSelector = function(clickEvent) {
+ if (this.fontMenu) {
+ this.fontMenu.remove();
+ this.fontMenu = null;
+ }
+ const list = this.fontList || [];
+ if (!list.length) return;
+ const buttonBottomLeft = this.getNodeScreenCoords(
+ this.fontSelectRect.x,
+ this.fontSelectRect.y + this.fontSelectRect.h
+ );
+ const buttonTopLeft = this.getNodeScreenCoords(
+ this.fontSelectRect.x,
+ this.fontSelectRect.y
+ );
+ const menu = document.createElement("div");
+ menu.style.cssText = 'position:fixed;background:#1a1a1a;border:1px solid #444;border-radius:6px;max-height:300px;overflow:hidden;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,0.5);min-width:200px;display:flex;flex-direction:column;';
+ this.fontMenu = menu;
+ const searchInput = document.createElement("input");
+ searchInput.type = "text";
+ searchInput.placeholder = "Search fonts...";
+ searchInput.value = "";
+ searchInput.style.cssText = 'width:100%;background:#222;color:#eee;border:none;border-bottom:1px solid #333;border-radius:6px 6px 0 0;padding:8px 12px;font-size:12px;outline:none;box-sizing:border-box;';
+ const listContainer = document.createElement("div");
+ listContainer.style.cssText = 'overflow-y:auto;flex:1;';
+ const renderList = (filter) => {
+ listContainer.innerHTML = '';
+ const lowerFilter = filter.toLowerCase();
+ const filtered = list.filter(f => f.toLowerCase().includes(lowerFilter));
+ if (filtered.length === 0) {
+ const empty = document.createElement("div");
+ empty.textContent = "No matches";
+ empty.style.cssText = 'padding:10px 15px;color:#666;font-size:12px;font-style:italic;';
+ listContainer.appendChild(empty);
+ return;
+ }
+ filtered.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 = (ev) => {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.textParams.font_name = opt;
+ menu.remove();
+ this.fontMenu = null;
+ this.setDirtyCanvas(true);
+ if (this.scheduleRender) this.scheduleRender('font_name');
+ this.syncAllWidgets();
+ };
+ listContainer.appendChild(item);
+ });
+ };
+ searchInput.addEventListener('input', (e) => {
+ renderList(e.target.value);
+ });
+ searchInput.addEventListener('mousedown', (e) => {
+ e.stopPropagation();
+ });
+ searchInput.addEventListener('keydown', (e) => {
+ e.stopPropagation();
+ if (e.key === 'Escape') {
+ menu.remove();
+ this.fontMenu = null;
+ }
+ });
+ menu.appendChild(searchInput);
+ menu.appendChild(listContainer);
+ menu.style.left = buttonBottomLeft.x + 'px';
+ menu.style.top = buttonBottomLeft.y + 'px';
+ document.body.appendChild(menu);
+ renderList('');
+ const menuHeight = menu.offsetHeight;
+ const menuWidth = menu.offsetWidth;
+ let finalLeft = buttonBottomLeft.x;
+ let finalTop = buttonBottomLeft.y;
+ if (finalTop + menuHeight > window.innerHeight - 10) {
+ finalTop = buttonTopLeft.y - menuHeight;
+ if (finalTop < 10) finalTop = 10;
+ }
+ if (finalLeft + menuWidth > window.innerWidth - 10) {
+ finalLeft = window.innerWidth - menuWidth - 10;
+ }
+ menu.style.left = finalLeft + 'px';
+ menu.style.top = finalTop + 'px';
+ setTimeout(() => {
+ searchInput.focus();
+ }, 50);
+ setTimeout(() => {
+ const closeHandler = (ev) => {
+ if (!menu.contains(ev.target)) {
+ menu.remove();
+ this.fontMenu = null;
+ document.removeEventListener("mousedown", closeHandler);
+ }
+ };
+ document.addEventListener("mousedown", closeHandler);
+ }, 100);
+ };
+
+ nodeType.prototype.showHexEditor = function() {
+ if (this.hexEditor) {
+ this.hexEditor.remove();
+ this.hexEditor = null;
+ }
+ const currentValue = this.textParams.text_color || '#FFFFFF';
+ const pos = this.getNodeScreenCoords(this.hexRect.x, this.hexRect.y);
+ const container = document.createElement('div');
+ container.style.cssText = 'position:fixed;z-index:10002;background:#1a1a1a;border:1px solid #4CAF50;border-radius:4px;padding:6px 8px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;align-items:center;gap:6px;';
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.value = currentValue;
+ input.style.cssText = 'width:80px;background:#222;color:#fff;border:1px solid #444;border-radius:3px;padding:4px 8px;font-size:11px;font-family:monospace;outline:none;text-transform:uppercase;';
+ const okBtn = document.createElement('button');
+ okBtn.textContent = 'OK';
+ okBtn.style.cssText = 'background:#2196F3;color:#fff;border:none;border-radius:3px;padding:4px 10px;font-size:11px;cursor:pointer;';
+ okBtn.onmouseover = () => okBtn.style.background = "#1976D2";
+ okBtn.onmouseout = () => okBtn.style.background = "#2196F3";
+ container.appendChild(input);
+ container.appendChild(okBtn);
+ document.body.appendChild(container);
+ const containerHeight = container.offsetHeight;
+ container.style.left = pos.x + 'px';
+ container.style.top = (pos.y - containerHeight * 1.5) + 'px';
+ this.hexEditor = container;
+ const isValidHex = (val) => {
+ const cleaned = val.replace('#', '');
+ return /^([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(cleaned);
+ };
+ const normalizeHex = (val) => {
+ let cleaned = val.replace('#', '').toUpperCase();
+ if (cleaned.length === 3) {
+ cleaned = cleaned.split('').map(c => c + c).join('');
+ }
+ return '#' + cleaned;
+ };
+ input.addEventListener('input', (e) => {
+ const val = e.target.value;
+ if (isValidHex(val)) {
+ input.style.borderColor = '#4CAF50';
+ const normalized = normalizeHex(val);
+ this.textParams.text_color = normalized;
+ this.setDirtyCanvas(true);
+ } else {
+ input.style.borderColor = '#dc3545';
+ }
+ });
+ const applyAndClose = () => {
+ const val = input.value;
+ if (isValidHex(val)) {
+ this.textParams.text_color = normalizeHex(val);
+ this.setDirtyCanvas(true);
+ }
+ container.remove();
+ this.hexEditor = null;
+ };
+ okBtn.onclick = (ev) => {
+ ev.stopPropagation();
+ ev.preventDefault();
+ applyAndClose();
+ };
+ input.addEventListener('keydown', (e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ applyAndClose();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ container.remove();
+ this.hexEditor = null;
+ }
+ });
+ input.addEventListener('blur', () => {
+ setTimeout(() => {
+ if (this.hexEditor === container) {
+ applyAndClose();
+ }
+ }, 100);
+ });
+ setTimeout(() => {
+ input.focus();
+ input.select();
+ }, 50);
+ };
+
+ nodeType.prototype.openColorPicker = function(clickEvent) {
+ const currentColor = this.textParams.text_color || '#FFFFFF';
+ const colorInput = document.createElement('input');
+ colorInput.type = 'color';
+ colorInput.value = currentColor;
+ colorInput.style.display = 'none';
+ document.body.appendChild(colorInput);
+ colorInput.addEventListener('change', (e) => {
+ const newColor = e.target.value.toUpperCase();
+ this.textParams.text_color = newColor;
+ this.setDirtyCanvas(true);
+ if (this.scheduleRender) this.scheduleRender('text_color');
+ this.syncAllWidgets();
+ colorInput.remove();
+ }, { once: true });
+ setTimeout(() => {
+ if (colorInput.showPicker) {
+ colorInput.showPicker();
+ } else {
+ colorInput.click();
+ }
+ }, 10);
+ };
+
+ nodeType.prototype.updateLineSpacingFromMouse = function(localX) {
+ if (!this.lineSpacingTrackRect) return;
+ const { x: trackX, w: trackW } = this.lineSpacingTrackRect;
+ let ratio = (localX - trackX) / trackW;
+ ratio = Math.max(0, Math.min(1, ratio));
+ const newValue = 0.5 + ratio * 2.5;
+ this.textParams.line_spacing = Math.round(newValue * 20) / 20;
+ this.setDirtyCanvas(true);
+ if (this.scheduleRender) this.scheduleRender('line_spacing');
+ this.syncAllWidgets();
+ };
+
+ nodeType.prototype.onResize = function(size) {
+ if (size[0] < this.minWidth) size[0] = this.minWidth;
+ const titleH = LiteGraph.NODE_TITLE_HEIGHT || 30;
+ const canvasTopPadding = 30;
+ const cSize = Math.max(200, size[0] - 40);
+ this.canvasPixelSize = cSize;
+ const textareaH = 60;
+ const alignH = 27;
+ const fontColorH = 27;
+ const btnH = 30;
+ const gaps = 10 + 8 + 8 + 20 + 15;
+ const neededHeight = titleH + canvasTopPadding + cSize + textareaH + alignH + fontColorH + btnH + gaps;
+ if (size[1] < neededHeight) size[1] = neededHeight;
+ this.setDirtyCanvas(true);
+ if (this.isEditing && this.backgroundImage) {
+ this.updateDisplaySize(cSize);
+ this.updateOverlayAbsolute();
+ this.computeAndApplyView();
+ }
+ };
+
+ nodeType.prototype.getCanvasMetrics = function() {
+ const titleH = LiteGraph.NODE_TITLE_HEIGHT || 30;
+ const canvasTopPadding = 30;
+ let cSize, rectX, rectY;
+ if (this.advancedMode) {
+ const w = this.overlayCanvas.width || (this.overlayCanvasWrapper ? this.overlayCanvasWrapper.clientWidth : 1000);
+ const h = this.overlayCanvas.height || (this.overlayCanvasWrapper ? this.overlayCanvasWrapper.clientHeight : 800);
+ cSize = Math.min(w, h);
+ rectX = 0; rectY = 0;
+ } else {
+ cSize = Math.max(200, this.size[0] - 40);
+ rectX = (this.size[0] - cSize) / 2;
+ rectY = titleH + canvasTopPadding;
+ }
+ return { cSize, rectX, rectY };
+ };
+
+ nodeType.prototype.drawAlignIcon = function(ctx, type, x, y, w, h, isActive) {
+ const lineHeight = 2;
+ const gap = 3;
+ const totalH = lineHeight*3 + gap*2;
+ const startY = y + (h - totalH)/2;
+ ctx.fillStyle = isActive ? "#4CAF50" : "#aaa";
+ if (type === "left") {
+ ctx.fillRect(x, startY, w*0.9, lineHeight);
+ ctx.fillRect(x, startY + lineHeight + gap, w*0.7, lineHeight);
+ ctx.fillRect(x, startY + (lineHeight+gap)*2, w*0.5, lineHeight);
+ } else if (type === "center") {
+ const w1 = w*0.9, w2 = w*0.7, w3 = w*0.5;
+ ctx.fillRect(x + (w-w1)/2, startY, w1, lineHeight);
+ ctx.fillRect(x + (w-w2)/2, startY + lineHeight + gap, w2, lineHeight);
+ ctx.fillRect(x + (w-w3)/2, startY + (lineHeight+gap)*2, w3, lineHeight);
+ } else if (type === "right") {
+ ctx.fillRect(x + w - w*0.9, startY, w*0.9, lineHeight);
+ ctx.fillRect(x + w - w*0.7, startY + lineHeight + gap, w*0.7, lineHeight);
+ ctx.fillRect(x + w - w*0.5, startY + (lineHeight+gap)*2, w*0.5, lineHeight);
+ }
+ };
+
+ nodeType.prototype.drawLineSpacingIcon = function(ctx, x, y, w, h) {
+ const lineY1 = y + h*0.3;
+ const lineY2 = y + h*0.7;
+ const arrowX = x + w/2;
+ ctx.strokeStyle = "#aaa";
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(x+2, lineY1);
+ ctx.lineTo(x+w-2, lineY1);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(x+2, lineY2);
+ ctx.lineTo(x+w-2, lineY2);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(arrowX, lineY1+2);
+ ctx.lineTo(arrowX, lineY2-2);
+ ctx.stroke();
+ ctx.fillStyle = "#aaa";
+ ctx.beginPath();
+ ctx.moveTo(arrowX, lineY1+1);
+ ctx.lineTo(arrowX-2, lineY1+4);
+ ctx.lineTo(arrowX+2, lineY1+4);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(arrowX, lineY2-1);
+ ctx.lineTo(arrowX-2, lineY2-4);
+ ctx.lineTo(arrowX+2, lineY2-4);
+ ctx.fill();
+ };
+
+ nodeType.prototype.onDrawForeground = function(ctx) {
+ if (this.advancedMode) {
+ ctx.clearRect(0, 0, this.size[0], this.size[1]);
+ return;
+ }
+ const { cSize, rectX, rectY } = this.getCanvasMetrics();
+ ctx.fillStyle = "#1e1e1e"; ctx.fillRect(rectX, rectY, cSize, cSize);
+ ctx.strokeStyle = "#555"; ctx.strokeRect(rectX, rectY, cSize, cSize);
+ this.updateDisplaySize(cSize);
+ if (this.isEditing && this.backgroundImage) {
+ this.updateOverlayAbsolute();
+ this.computeAndApplyView();
+ }
+ if (!this.dragState) { this.updateOverlayAbsolute(); this.computeAndApplyView(); }
+ if (this.isLoading) {
+ ctx.fillStyle = "#888"; ctx.font = "12px Arial"; ctx.fillText("Loading...", rectX + cSize/2 - 35, rectY + cSize/2);
+ } else if (this.isEditing && this.backgroundImage) {
+ const useScale = this.dragState ? this.dragState.viewScale : this.viewScale;
+ const useOffsetX = this.dragState ? this.dragState.viewOffsetX : this.viewOffsetX;
+ const useOffsetY = this.dragState ? this.dragState.viewOffsetY : this.viewOffsetY;
+ ctx.save(); ctx.translate(rectX + useOffsetX, rectY + useOffsetY); ctx.scale(useScale, useScale);
+ ctx.drawImage(this.backgroundImage, -this.displayWidth/2, -this.displayHeight/2, this.displayWidth, this.displayHeight);
+ if (this.textMask) {
+ ctx.save();
+ ctx.translate(this.overlay.x, this.overlay.y);
+ ctx.rotate(this.overlay.rotation * Math.PI / 180);
+ const rectW = this.overlay.width;
+ const rectH = this.overlay.height;
+ this.drawTextOverlay(ctx, -rectW / 2, -rectH / 2, rectW, rectH);
+ if (this.advancedMode) {
+ ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 4/useScale; ctx.strokeStyle = "#00FF00"; ctx.lineWidth = 1/useScale;
+ ctx.strokeRect(-rectW/2, -rectH/2, rectW, rectH); ctx.shadowColor = "transparent"; ctx.shadowBlur = 0;
+ const hw = rectW/2, hh = rectH/2, hs = 6/useScale; ctx.fillStyle = "#FF0000";
+ [[hw, hh], [-hw, hh], [hw, -hh], [-hw, -hh]].forEach(([x, y]) => ctx.fillRect(x - hs/2, y - hs/2, hs, hs));
+ [[hw, 0], [-hw, 0], [0, hh], [0, -hh]].forEach(([x, y]) => ctx.fillRect(x - hs/2, y - hs/2, hs, hs));
+ const rotHandleY = -hh - 40; ctx.beginPath(); ctx.arc(0, rotHandleY, 7/useScale, 0, Math.PI*2); ctx.fillStyle = "#ff9800"; ctx.fill(); ctx.strokeStyle = "#fff"; ctx.lineWidth = 1/useScale; ctx.stroke();
+ }
+ ctx.restore();
+ }
+ ctx.restore();
+ if (this.advancedMode) {
+ ctx.fillStyle = "#ff9800"; ctx.font = "12px Arial"; ctx.fillText(`EDITING (Scale: ${(useScale*100).toFixed(0)}%)`, rectX + cSize - 160, rectY + cSize - 10);
+ }
+ } else {
+ ctx.fillStyle = "#888"; ctx.font = "12px Arial"; ctx.fillText("▶ Run queue to start", rectX + cSize/2 - 65, rectY + cSize/2);
+ }
+ const toggleBtnW = 150, toggleBtnH = 24, toggleBtnX = (this.size[0] - toggleBtnW)/2, toggleBtnY = 20;
+ ctx.fillStyle = "#2a2a2a"; ctx.strokeStyle = "#2196F3"; ctx.lineWidth = 2;
+ this.roundRect(ctx, toggleBtnX, toggleBtnY, toggleBtnW, toggleBtnH, 6);
+ ctx.fill(); ctx.stroke();
+ ctx.fillStyle = "#2196F3"; ctx.font = "bold 11px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
+ ctx.fillText("🔍 ADVANCED MODE", toggleBtnX + toggleBtnW/2, toggleBtnY + toggleBtnH/2 + 4);
+ const widgetX = 15;
+ const widgetW = this.size[0] - 30;
+ const textareaY = rectY + cSize + 10;
+ const textareaH = 60;
+ this.textareaRect = { x: widgetX, y: textareaY, w: widgetW, h: textareaH };
+ ctx.fillStyle = this.textareaHover ? "#2a2a2a" : "#252525";
+ ctx.strokeStyle = this.textareaHover ? "#4CAF50" : "#444";
+ ctx.lineWidth = 1;
+ this.roundRect(ctx, widgetX, textareaY, widgetW, textareaH, 4);
+ ctx.fill();
+ ctx.stroke();
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(widgetX+1, textareaY+1, widgetW-2, textareaH-2);
+ ctx.clip();
+ ctx.font = "12px monospace";
+ ctx.textAlign = "left";
+ ctx.textBaseline = "top";
+ const text = this.textParams.text || "";
+ const lines = text.split('\n');
+ const maxLines = 3;
+ const lineHeight = 16;
+ const paddingX = 8;
+ const paddingY = 6;
+ if (!text) {
+ ctx.fillStyle = "#666";
+ ctx.fillText("Your text…", widgetX+paddingX, textareaY+paddingY);
+ } else {
+ for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
+ let line = lines[i];
+ const maxWidth = widgetW - paddingX*2;
+ while (ctx.measureText(line).width > maxWidth && line.length > 0) {
+ line = line.slice(0, -1);
+ }
+ if (line !== lines[i]) line += "…";
+ ctx.fillStyle = "#aaa";
+ ctx.fillText(line, widgetX+paddingX, textareaY+paddingY+i*lineHeight);
+ }
+ if (lines.length > maxLines) {
+ ctx.fillStyle = "#666";
+ ctx.fillText("…", widgetX+paddingX, textareaY+paddingY+maxLines*lineHeight);
+ }
+ }
+ ctx.restore();
+ const alignY = textareaY + textareaH + 8;
+ const alignH = 27;
+ const padding = 5;
+ const availableW = widgetW - padding*2;
+ const sliderW = availableW * 0.7;
+ const sliderX = widgetX + padding;
+ this.lineSpacingRect = { x: sliderX, y: alignY, w: sliderW, h: alignH };
+ const btnW = availableW * 0.1;
+ const btnGap = 4;
+ const btnStartX = sliderX + sliderW + btnGap;
+ const alignTypes = ["left", "center", "right"];
+ this.alignButtonsRects = [];
+ const isMultiline = text.includes('\n');
+ ctx.fillStyle = this.lineSpacingHover ? "#2a2a2a" : "#252525";
+ ctx.strokeStyle = this.lineSpacingHover ? "#4CAF50" : "#444";
+ ctx.lineWidth = 1;
+ this.roundRect(ctx, sliderX, alignY, sliderW, alignH, 4);
+ ctx.fill();
+ ctx.stroke();
+ this.drawLineSpacingIcon(ctx, sliderX+6, alignY+6, 14, 15);
+ const trackX = sliderX + 24;
+ const trackW = sliderW - 70;
+ const trackY = alignY + alignH/2;
+ const trackH = 3;
+ this.lineSpacingTrackRect = { x: trackX, y: alignY, w: trackW, h: alignH };
+ ctx.fillStyle = "#444";
+ ctx.fillRect(trackX, trackY - trackH/2, trackW, trackH);
+ const lineSpacingVal = parseFloat(this.textParams.line_spacing) || 1.0;
+ const fillRatio = Math.max(0, Math.min(1, (lineSpacingVal - 0.5)/2.5));
+ const fillW = trackW * fillRatio;
+ ctx.fillStyle = "#4CAF50";
+ ctx.fillRect(trackX, trackY - trackH/2, fillW, trackH);
+ const handleX = trackX + fillW;
+ const handleSize = 8;
+ ctx.fillStyle = "#fff";
+ ctx.beginPath();
+ ctx.arc(handleX, trackY, handleSize/2, 0, Math.PI*2);
+ ctx.fill();
+ const valueW = 42;
+ const valueX = sliderX + sliderW - valueW - 4;
+ this.lineSpacingValueRect = { x: valueX, y: alignY, w: valueW, h: alignH };
+ ctx.fillStyle = this.lineSpacingValueHover ? "#2a2a2a" : "#222";
+ ctx.strokeStyle = this.lineSpacingValueHover ? "#4CAF50" : "#444";
+ ctx.lineWidth = 1;
+ this.roundRect(ctx, valueX, alignY+3, valueW, alignH-6, 3);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = "#4CAF50";
+ ctx.font = "10px monospace";
+ ctx.textAlign = "center";
+ ctx.fillText(lineSpacingVal.toFixed(2), valueX+valueW/2, alignY+alignH/2+3);
+ for (let i = 0; i < 3; i++) {
+ const bx = btnStartX + i*(btnW+btnGap);
+ const isActive = this.textParams.text_align === alignTypes[i];
+ const isHover = this.alignButtonsHover[i];
+ const isEnabled = isMultiline;
+ this.alignButtonsRects.push({ x: bx, y: alignY, w: btnW, h: alignH, type: alignTypes[i], enabled: isEnabled });
+ ctx.fillStyle = isHover && isEnabled ? "#2a2a2a" : "#252525";
+ ctx.strokeStyle = isActive && isEnabled ? "#4CAF50" : (isHover && isEnabled ? "#4CAF50" : "#444");
+ ctx.lineWidth = isActive && isEnabled ? 2 : 1;
+ ctx.globalAlpha = isEnabled ? 1.0 : 0.4;
+ this.roundRect(ctx, bx, alignY, btnW, alignH, 4);
+ ctx.fill();
+ ctx.stroke();
+ ctx.globalAlpha = 1.0;
+ if (isEnabled) {
+ this.drawAlignIcon(ctx, alignTypes[i], bx + (btnW-14)/2, alignY + (alignH-10)/2, 14, 10, isActive);
+ }
+ }
+ const fontColorY = alignY + alignH + 8;
+ const fontColorH = 27;
+ const gap = 8;
+ const fontSelectW = Math.floor((widgetW - gap) * 0.6);
+ const colorPickerW = widgetW - fontSelectW - gap;
+ const fontSelectX = widgetX;
+ const colorPickerX = widgetX + fontSelectW + gap;
+ this.fontColorY = fontColorY;
+ this.fontSelectRect = { x: fontSelectX, y: fontColorY, w: fontSelectW, h: fontColorH };
+ ctx.fillStyle = this.fontSelectHover ? "#2a2a2a" : "#252525";
+ ctx.strokeStyle = this.fontSelectHover ? "#4CAF50" : "#444";
+ ctx.lineWidth = 1;
+ this.roundRect(ctx, fontSelectX, fontColorY, fontSelectW, fontColorH, 4);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = "#fff";
+ ctx.font = "11px sans-serif";
+ ctx.textAlign = "center";
+ const fontName = this.textParams.font_name || "Fonts not found";
+ ctx.fillText(fontName, fontSelectX+fontSelectW/2, fontColorY+fontColorH/2+4);
+ ctx.fillStyle = "#666";
+ ctx.beginPath();
+ ctx.moveTo(fontSelectX+fontSelectW-12, fontColorY+fontColorH/2-3);
+ ctx.lineTo(fontSelectX+fontSelectW-6, fontColorY+fontColorH/2-3);
+ ctx.lineTo(fontSelectX+fontSelectW-9, fontColorY+fontColorH/2+3);
+ ctx.fill();
+ const colorBoxSize = 24;
+ const colorBoxY = fontColorY + Math.round((fontColorH - colorBoxSize)/2);
+ const hexW = colorPickerW - colorBoxSize - 5;
+ const hexX = colorPickerX;
+ const colorBoxX = colorPickerX + hexW + 5;
+ this.hexRect = { x: hexX, y: fontColorY, w: hexW, h: fontColorH };
+ this.colorBoxRect = { x: colorBoxX, y: colorBoxY, w: colorBoxSize, h: colorBoxSize };
+ ctx.fillStyle = this.hexHover ? "#2a2a2a" : "#252525";
+ ctx.fillRect(hexX, fontColorY, hexW, fontColorH);
+ ctx.strokeStyle = this.hexHover ? "#4CAF50" : "#444";
+ ctx.lineWidth = 1;
+ ctx.strokeRect(hexX, fontColorY, hexW, fontColorH);
+ ctx.fillStyle = "#fff";
+ ctx.font = "11px sans-serif";
+ ctx.textAlign = "center";
+ ctx.fillText(this.textParams.text_color || "#FFFFFF", hexX+hexW/2, fontColorY+fontColorH/2+4);
+ ctx.fillStyle = this.textParams.text_color || "#FFFFFF";
+ this.roundRect(ctx, colorBoxX, colorBoxY, colorBoxSize, colorBoxSize, 4);
+ ctx.fill();
+ ctx.strokeStyle = this.colorBoxHover ? "#4CAF50" : "#fff";
+ ctx.lineWidth = 1.5;
+ this.roundRect(ctx, colorBoxX, colorBoxY, colorBoxSize, colorBoxSize, 4);
+ ctx.stroke();
+ const buttonsY = fontColorY + fontColorH + 20;
+ this.buttonsY = buttonsY;
+ const btnH = 30;
+ const btnGap2 = 10;
+ const btnW2 = (this.size[0] - 35) / 2;
+ [[15, buttonsY, "✔️ APPLY", this.btnApplyHover, "#4CAF50"], [15+btnW2+btnGap2, buttonsY, " CANCEL", this.btnCancelHover, "#dc3545"]].forEach(([bx, by, txt, hov, col]) => {
+ ctx.fillStyle = hov ? "#444" : "#2a2a2a";
+ this.roundRect(ctx, bx, by, btnW2, btnH, 6);
+ ctx.fill();
+ ctx.strokeStyle = col;
+ ctx.stroke();
+ ctx.fillStyle = col;
+ ctx.font = "bold 11px Arial";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "alphabetic";
+ ctx.fillText(txt, bx+btnW2/2, by+btnH/2+4);
+ });
+ };
+
+ nodeType.prototype.onMouseDown = function(event, pos) {
+ if (!this.advancedMode && pos) {
+ const toggleBtnW = 150, toggleBtnH = 24, toggleBtnX = (this.size[0] - toggleBtnW)/2, toggleBtnY = 20;
+ if (pos[0] >= toggleBtnX && pos[0] <= toggleBtnX + toggleBtnW && pos[1] >= toggleBtnY && pos[1] <= toggleBtnY + toggleBtnH) {
+ this._toggleAdvancedMode();
+ return true;
+ }
+ }
+ if (this.textareaRect) {
+ const { x, y, w, h } = this.textareaRect;
+ if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) {
+ const canvas = LGraphCanvas.active_canvas;
+ if (canvas) {
+ const rect = canvas.canvas.getBoundingClientRect();
+ const ds = canvas.ds;
+ this.lastClickCoords = {
+ clientX: rect.left + (this.pos[0] + pos[0]) * ds.scale + ds.offset[0],
+ clientY: rect.top + (this.pos[1] + pos[1]) * ds.scale + ds.offset[1]
+ };
+ }
+ this.openTextarea(this.lastClickCoords);
+ return true;
+ }
+ }
+ if (this.lineSpacingValueRect) {
+ const { x, y, w, h } = this.lineSpacingValueRect;
+ if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) {
+ if (this.lineSpacingPopup) {
+ this.lineSpacingPopup.remove();
+ this.lineSpacingPopup = null;
+ }
+ const currentValue = this.textParams.line_spacing;
+ const popup = document.createElement('div');
+ popup.style.cssText = 'position:fixed;z-index:10003;background:#1a1a1a;border:1px solid #444;border-radius:6px;padding:8px 12px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;align-items:center;gap:8px;';
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.value = currentValue;
+ input.min = 0.5;
+ input.max = 3.0;
+ input.step = 0.05;
+ input.style.cssText = 'width:100px;background:#222;color:#fff;border:1px solid #444;border-radius:4px;padding:6px 10px;font-size:12px;outline:none;';
+ const saveBtn = document.createElement('button');
+ saveBtn.textContent = 'OK';
+ saveBtn.style.cssText = 'background:#2196F3;color:#fff;border:none;border-radius:4px;padding:6px 12px;font-size:12px;cursor:pointer;';
+ const doSave = () => {
+ let num = parseFloat(input.value);
+ if (isNaN(num)) num = currentValue;
+ num = Math.max(0.5, Math.min(3.0, num));
+ this.textParams.line_spacing = num;
+ popup.remove();
+ this.lineSpacingPopup = null;
+ if (this.scheduleRender) this.scheduleRender('line_spacing');
+ this.syncAllWidgets();
+ };
+ saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); doSave(); };
+ input.onkeydown = (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); doSave(); } };
+ popup.appendChild(input);
+ popup.appendChild(saveBtn);
+ document.body.appendChild(popup);
+ const screenPos = this.getNodeScreenCoords(x, y);
+ const popupWidth = popup.offsetWidth;
+ const popupHeight = popup.offsetHeight;
+ let leftPos = screenPos.x;
+ let topPos = screenPos.y - popupHeight - 8;
+ if (topPos < 8) topPos = screenPos.y + 8;
+ if (leftPos + popupWidth > window.innerWidth - 8) leftPos = window.innerWidth - popupWidth - 8;
+ popup.style.left = leftPos + 'px';
+ popup.style.top = topPos + 'px';
+ this.lineSpacingPopup = popup;
+ setTimeout(() => { input.focus(); input.select(); }, 50);
+ setTimeout(() => {
+ const closeHandler = (ev) => {
+ if (!popup.contains(ev.target)) {
+ popup.remove();
+ this.lineSpacingPopup = null;
+ document.removeEventListener('mousedown', closeHandler);
+ }
+ };
+ document.addEventListener('mousedown', closeHandler);
+ }, 100);
+ return true;
+ }
+ }
+ if (this.lineSpacingTrackRect) {
+ const { x, y, w, h } = this.lineSpacingTrackRect;
+ if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) {
+ this.lineSpacingDragging = true;
+ this.updateLineSpacingFromMouse(pos[0]);
+ return true;
+ }
+ }
+ if (this.alignButtonsRects && this.alignButtonsRects.length === 3) {
+ for (let i = 0; i < 3; i++) {
+ const { x, y, w, h, type, enabled } = this.alignButtonsRects[i];
+ if (enabled && pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) {
+ this.textParams.text_align = type;
+ this.setDirtyCanvas(true);
+ if (this.scheduleRender) this.scheduleRender('text_align');
+ this.syncAllWidgets();
+ return true;
+ }
+ }
+ }
+ if (this.fontSelectRect) {
+ const { x, y, w, h } = this.fontSelectRect;
+ if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) {
+ this.showFontSelector();
+ return true;
+ }
+ }
+ if (this.hexRect) {
+ const { x, y, w, h } = this.hexRect;
+ if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) {
+ this.showHexEditor();
+ return true;
+ }
+ }
+ if (this.colorBoxRect) {
+ const { x, y, w, h } = this.colorBoxRect;
+ if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) {
+ this.openColorPicker();
+ return true;
+ }
+ }
+ if (pos && this.buttonsY !== undefined) {
+ const btnH = 30;
+ const btnGap = 10;
+ const btnW = (this.size[0] - 35) / 2;
+ const y1 = this.buttonsY;
+ if (pos[0] >= 15 && pos[0] <= 15 + btnW && pos[1] >= y1 && pos[1] <= y1 + btnH) {
+ this.sendTransforms();
+ return true;
+ }
+ if (pos[0] >= 15 + btnW + btnGap && pos[0] <= 15 + btnW + btnGap + btnW && pos[1] >= y1 && pos[1] <= y1 + btnH) {
+ this.cancelEditing();
+ return true;
+ }
+ }
+
+ if (!this.advancedMode) return false;
+
+ if (!this.isEditing || this.isLoading || !this.textMask) return;
+ const { cSize, rectX, rectY } = this.getCanvasMetrics();
+ const mx = pos[0], my = pos[1];
+ const frozenScale = this.viewScale, frozenOffsetX = this.viewOffsetX, frozenOffsetY = this.viewOffsetY;
+ const worldMx = (mx - rectX - frozenOffsetX) / frozenScale, worldMy = (my - rectY - frozenOffsetY) / frozenScale;
+ const screenHandles = this.computeScreenHandles(rectX, rectY, frozenScale, frozenOffsetX, frozenOffsetY);
+ const cornerSize = 14, edgeSize = 18, rotateSize = 26;
+ let detectedType = null, minDist = Infinity;
+ const checkHandle = (name, h, threshold) => {
+ const dist = Math.hypot(mx - h.x, my - h.y);
+ if (dist < threshold && dist < minDist) {
+ detectedType = name;
+ minDist = dist;
+ }
+ };
+ for (const [name, hPos] of Object.entries(screenHandles)) {
+ const isEdge = ['scale-t', 'scale-b', 'scale-l', 'scale-r'].includes(name);
+ const threshold = name === 'rotate' ? rotateSize : (isEdge ? edgeSize : cornerSize);
+ checkHandle(name, hPos, threshold);
+ }
+ this.dragType = detectedType;
+ if (this.dragType) {
+ this.isDragging = true;
+ this.awaitingServerRender = false;
+ const startW = this.overlay.width;
+ const startH = this.overlay.height;
+ const startRot = this.overlay.rotation * Math.PI / 180;
+ const startCX = this.overlay.x;
+ const startCY = this.overlay.y;
+ const localPoints = {
+ 'scale-br': { active: [startW/2, startH/2], fixed: [-startW/2, -startH/2] },
+ 'scale-bl': { active: [-startW/2, startH/2], fixed: [startW/2, -startH/2] },
+ 'scale-tr': { active: [startW/2, -startH/2], fixed: [-startW/2, startH/2] },
+ 'scale-tl': { active: [-startW/2, -startH/2], fixed: [startW/2, startH/2] },
+ 'scale-r': { active: [startW/2, 0], fixed: [-startW/2, 0] },
+ 'scale-l': { active: [-startW/2, 0], fixed: [startW/2, 0] },
+ 'scale-b': { active: [0, startH/2], fixed: [0, -startH/2] },
+ 'scale-t': { active: [0, -startH/2], fixed: [0, startH/2] }
+ };
+ const pts = localPoints[this.dragType];
+ const fixedLocal = pts ? { x: pts.fixed[0], y: pts.fixed[1] } : { x: 0, y: 0 };
+ const activeLocal = pts ? { x: pts.active[0], y: pts.active[1] } : { x: 0, y: 0 };
+ this.dragState = {
+ startMouseX: worldMx, startMouseY: worldMy,
+ startX: this.overlay.x, startY: this.overlay.y,
+ startW: startW, startH: startH,
+ startRotation: this.overlay.rotation,
+ startRot: startRot,
+ startCX: startCX,
+ startCY: startCY,
+ viewScale: frozenScale, viewOffsetX: frozenOffsetX, viewOffsetY: frozenOffsetY,
+ fixedLocal: fixedLocal,
+ activeLocal: activeLocal
+ };
+ return true;
+ }
+ const halfW = this.overlay.width / 2;
+ const halfH = this.overlay.height / 2;
+ const dx = worldMx - this.overlay.x, dy = worldMy - this.overlay.y;
+ const rotRad = -this.overlay.rotation * Math.PI / 180;
+ const localX = dx * Math.cos(rotRad) - dy * Math.sin(rotRad), localY = dx * Math.sin(rotRad) + dy * Math.cos(rotRad);
+ if (Math.abs(localX) < halfW && Math.abs(localY) < halfH) {
+ this.dragType = 'move';
+ this.dragState = { startMouseX: worldMx, startMouseY: worldMy, startX: this.overlay.x, startY: this.overlay.y, startW: this.overlay.width, startH: this.overlay.height, startRotation: this.overlay.rotation, startRot: this.overlay.rotation * Math.PI / 180, viewScale: frozenScale, viewOffsetX: frozenOffsetX, viewOffsetY: frozenOffsetY };
+ return true;
+ }
+ return false;
+ };
+
+ nodeType.prototype.onMouseMove = function(event, pos) {
+ if (this.lineSpacingDragging && pos) {
+ this.updateLineSpacingFromMouse(pos[0]);
+ return;
+ }
+ const prevTextareaHover = this.textareaHover;
+ if (this.textareaRect && !this.textareaState.isOpen) {
+ const { x, y, w, h } = this.textareaRect;
+ this.textareaHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h;
+ } else {
+ this.textareaHover = false;
+ }
+ if (prevTextareaHover !== this.textareaHover) this.setDirtyCanvas(true);
+ const prevLineSpacingHover = this.lineSpacingHover;
+ if (this.lineSpacingRect) {
+ const { x, y, w, h } = this.lineSpacingRect;
+ this.lineSpacingHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h;
+ } else {
+ this.lineSpacingHover = false;
+ }
+ if (prevLineSpacingHover !== this.lineSpacingHover) this.setDirtyCanvas(true);
+ const prevLineSpacingValueHover = this.lineSpacingValueHover || false;
+ if (this.lineSpacingValueRect) {
+ const { x, y, w, h } = this.lineSpacingValueRect;
+ this.lineSpacingValueHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h;
+ } else {
+ this.lineSpacingValueHover = false;
+ }
+ if (prevLineSpacingValueHover !== this.lineSpacingValueHover) this.setDirtyCanvas(true);
+ const prevAlignHover = [...this.alignButtonsHover];
+ if (this.alignButtonsRects && this.alignButtonsRects.length === 3) {
+ for (let i = 0; i < 3; i++) {
+ const { x, y, w, h, enabled } = this.alignButtonsRects[i];
+ this.alignButtonsHover[i] = enabled && pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h;
+ }
+ } else {
+ this.alignButtonsHover = [false, false, false];
+ }
+ if (prevAlignHover.some((v, i) => v !== this.alignButtonsHover[i])) this.setDirtyCanvas(true);
+ const prevFontHover = this.fontSelectHover;
+ if (this.fontSelectRect) {
+ const { x, y, w, h } = this.fontSelectRect;
+ this.fontSelectHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h;
+ } else {
+ this.fontSelectHover = false;
+ }
+ if (prevFontHover !== this.fontSelectHover) this.setDirtyCanvas(true);
+ const prevHexHover = this.hexHover;
+ if (this.hexRect) {
+ const { x, y, w, h } = this.hexRect;
+ this.hexHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h;
+ } else {
+ this.hexHover = false;
+ }
+ if (prevHexHover !== this.hexHover) this.setDirtyCanvas(true);
+ const prevColorBoxHover = this.colorBoxHover;
+ if (this.colorBoxRect) {
+ const { x, y, w, h } = this.colorBoxRect;
+ this.colorBoxHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h;
+ } else {
+ this.colorBoxHover = false;
+ }
+ if (prevColorBoxHover !== this.colorBoxHover) this.setDirtyCanvas(true);
+ const prevBtnApply = this.btnApplyHover;
+ const prevBtnCancel = this.btnCancelHover;
+ if (this.buttonsY !== undefined) {
+ const btnH = 30;
+ const btnGap = 10;
+ const btnW = (this.size[0] - 35) / 2;
+ const y1 = this.buttonsY;
+ this.btnApplyHover = pos[0] >= 15 && pos[0] <= 15 + btnW && pos[1] >= y1 && pos[1] <= y1 + btnH;
+ this.btnCancelHover = pos[0] >= 15 + btnW + btnGap && pos[0] <= 15 + btnW + btnGap + btnW && pos[1] >= y1 && pos[1] <= y1 + btnH;
+ }
+ if (prevBtnApply !== this.btnApplyHover || prevBtnCancel !== this.btnCancelHover) this.setDirtyCanvas(true);
+
+ if (!this.advancedMode) return;
+
+ if (!this.dragType || !this.isEditing || this.isLoading || !this.dragState) return;
+ const { rectX, rectY } = this.getCanvasMetrics();
+ const mx = pos[0], my = pos[1];
+ const worldMx = (mx - rectX - this.dragState.viewOffsetX) / this.dragState.viewScale;
+ const worldMy = (my - rectY - this.dragState.viewOffsetY) / this.dragState.viewScale;
+ if (this.dragType === 'move') {
+ this.overlay.x = this.dragState.startX + (worldMx - this.dragState.startMouseX);
+ this.overlay.y = this.dragState.startY + (worldMy - this.dragState.startMouseY);
+ }
+ else if (this.dragType === 'rotate') {
+ const cx = this.overlay.x, cy = this.overlay.y;
+ const sA = Math.atan2(this.dragState.startMouseY - cy, this.dragState.startMouseX - cx);
+ const cA = Math.atan2(worldMy - cy, worldMx - cx);
+ this.overlay.rotation = this.dragState.startRotation + (cA - sA) * 180 / Math.PI;
+ }
+ else if (this.dragType.startsWith('scale')) {
+ const { startCX, startCY, startW, startH, startRot, fixedLocal, activeLocal } = this.dragState;
+ const { x: fx, y: fy } = fixedLocal;
+ const { x: ax0, y: ay0 } = activeLocal;
+ const cosR = Math.cos(startRot), sinR = Math.sin(startRot);
+ const fixedWorldX = startCX + fx*cosR - fy*sinR;
+ const fixedWorldY = startCY + fx*sinR + fy*cosR;
+ const dxw = worldMx - fixedWorldX;
+ const dyw = worldMy - fixedWorldY;
+ const ldx = dxw*cosR + dyw*sinR;
+ const ldy = -dxw*sinR + dyw*cosR;
+ let newW = startW, newH = startH;
+ const isCorner = (ax0 !== 0 && ay0 !== 0);
+ if (ax0 !== 0) {
+ const signX = Math.sign(ax0);
+ newW = 2 * (ldx + fx) / signX;
+ newW = Math.max(40, newW);
+ }
+ if (ay0 !== 0) {
+ const signY = Math.sign(ay0);
+ newH = 2 * (ldy + fy) / signY;
+ newH = Math.max(40, newH);
+ }
+ if (isCorner) {
+ const scaleX = newW / startW;
+ const scaleY = newH / startH;
+ const scale = Math.max(scaleX, scaleY);
+ newW = startW * scale;
+ newH = startH * scale;
+ }
+ const newCX = fixedWorldX - (fx*cosR - fy*sinR);
+ const newCY = fixedWorldY - (fx*sinR + fy*cosR);
+ this.overlay.x = newCX;
+ this.overlay.y = newCY;
+ this.overlay.width = newW;
+ this.overlay.height = newH;
+ this.textParams.text_scale_x = this.overlay.width / this.baseWidth;
+ this.textParams.text_scale_y = this.overlay.height / this.baseHeight;
+ }
+ this.setDirtyCanvas(true);
+ };
+
+ nodeType.prototype.onMouseUp = function() {
+ if (this.lineSpacingDragging) {
+ this.lineSpacingDragging = false;
+ this.setDirtyCanvas(true);
+ }
+ if (!this.advancedMode) {
+ this.dragType = null;
+ this.dragState = null;
+ this.setDirtyCanvas(true);
+ return;
+ }
+ if (this.dragType) {
+ const wasScale = this.dragType.startsWith('scale');
+ const wasRotate = this.dragType === 'rotate';
+ if (wasScale) {
+ this.awaitingServerRender = true;
+ }
+ this.updateRelativeFromAbsolute();
+ if (!this.advancedMode) {
+ this.computeAndApplyView();
+ }
+ if (wasScale || wasRotate) {
+ if (this.currentRenderAbortController) {
+ this.currentRenderAbortController.abort();
+ this.currentRenderAbortController = null;
+ }
+ if (this.renderTimeout) clearTimeout(this.renderTimeout);
+ this.currentRenderAbortController = new AbortController();
+ const signal = this.currentRenderAbortController.signal;
+ this.renderMasks(signal);
+ }
+ }
+ this.isDragging = false;
+ this.dragType = null;
+ this.dragState = null;
+ };
+ }
+});
\ No newline at end of file
diff --git a/web/rs_text_overlay.js b/web/rs_text_overlay.js
deleted file mode 100644
index be6da37..0000000
--- a/web/rs_text_overlay.js
+++ /dev/null
@@ -1,424 +0,0 @@
-import { app } from "../../scripts/app.js";
-
-app.registerExtension({
- name: "RaykoTextOverlay",
- async beforeRegisterNodeDef(nodeType, nodeData, app) {
- if (nodeData.name === "RS_TextOverlay") {
- const onNodeCreated = nodeType.prototype.onNodeCreated;
- nodeType.prototype.onNodeCreated = function() {
- const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
-
- this.data = {
- 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,
- enable_shadow: false,
- shadow_color: "#333333",
- shadow_offset_x: 10,
- shadow_offset_y: 10,
- shadow_blur: 0.2,
- shadow_opacity: 0.8,
- enable_glow: false,
- glow_color: "#FFFFFF",
- glow_size: 150,
- glow_spread: 250,
- glow_brightness: 1.0,
- font_list: ["default"],
- use_supersampling: false,
- use_edge_smoothing: true,
- supersampling_factor: 2
- };
-
- 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("Error loading saved data", e);
- }
- }
-
- if (this.widgets) {
- this.widgets.forEach(w => w.hidden = true);
- }
-
- this.setSize([this.targetWidth, 750]);
- 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;
-
- 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 });
- this.widgetYPositions["text"] = y;
- y += rowH * 2 + 5;
-
- 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;
-
- 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;
-
- 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;
-
- 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;
-
- this.drawSeparator(ctx, "FORMATTING TEXT", pad, y, inputW + labelW, rowH);
- y += rowH + 5;
-
- 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;
-
- 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;
-
- 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;
-
- 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;
-
- this.drawSeparator(ctx, "GLOW SETTINGS", pad, y, inputW + labelW, rowH);
- y += rowH + 5;
-
- this.drawLabel(ctx, "ENABLE GLOW", pad, y, labelW, rowH);
- this.drawBooleanField(ctx, this.data.enable_glow, pad + labelW, y, inputW, rowH);
- this.widgetYPositions["enable_glow"] = y;
- this.clickZones.push({ type: "boolean", field: "enable_glow", x: pad + labelW, y: y, w: inputW, h: rowH });
- y += rowH + 5;
-
- this.drawLabel(ctx, "GLOW COLOR", pad, y, labelW, rowH);
- this.drawColorField(ctx, this.data.glow_color, pad + labelW, y, inputW, rowH);
- this.widgetYPositions["glow_color"] = y;
- this.clickZones.push({ type: "color", field: "glow_color", x: pad + labelW, y: y, w: inputW, h: rowH });
- y += rowH + 5;
-
- this.drawLabel(ctx, "GLOW SIZE", pad, y, labelW, rowH);
- this.drawNumberFieldWithArrows(ctx, this.data.glow_size, pad + labelW, y, inputW, rowH, arrowW);
- this.widgetYPositions["glow_size"] = y;
- this.clickZones.push({ type: "number", field: "glow_size", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 500 });
- this.clickZones.push({ type: "arrow_left", field: "glow_size", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 });
- this.clickZones.push({ type: "arrow_right", field: "glow_size", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 });
- y += rowH + 5;
-
- this.drawLabel(ctx, "GLOW SPREAD", pad, y, labelW, rowH);
- this.drawNumberFieldWithArrows(ctx, this.data.glow_spread, pad + labelW, y, inputW, rowH, arrowW);
- this.widgetYPositions["glow_spread"] = y;
- this.clickZones.push({ type: "number", field: "glow_spread", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 500 });
- this.clickZones.push({ type: "arrow_left", field: "glow_spread", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 });
- this.clickZones.push({ type: "arrow_right", field: "glow_spread", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 });
- y += rowH + 5;
-
- this.drawLabel(ctx, "GLOW BRIGHTNESS", pad, y, labelW, rowH);
- this.drawNumberFieldWithArrows(ctx, this.data.glow_brightness, pad + labelW, y, inputW, rowH, arrowW);
- this.widgetYPositions["glow_brightness"] = y;
- this.clickZones.push({ type: "number", field: "glow_brightness", 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: "glow_brightness", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.02, float: true });
- this.clickZones.push({ type: "arrow_right", field: "glow_brightness", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.02, float: true });
- y += rowH + 5;
-
- this.drawSeparator(ctx, "SHADOW SETTINGS", pad, y, inputW + labelW, rowH);
- y += rowH + 5;
-
- 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;
-
- 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;
-
- 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: -500, max: 500 });
- this.clickZones.push({ type: "arrow_left", field: "shadow_offset_x", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 });
- this.clickZones.push({ type: "arrow_right", field: "shadow_offset_x", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 });
- y += rowH + 5;
-
- 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: -500, max: 500 });
- this.clickZones.push({ type: "arrow_left", field: "shadow_offset_y", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 });
- this.clickZones.push({ type: "arrow_right", field: "shadow_offset_y", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 });
- y += rowH + 5;
-
- 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;
-
- this.drawLabel(ctx, "SHADOW BRIGHTNESS", 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 + 5;
-
- this.drawSeparator(ctx, "QUALITY SETTINGS", pad, y, inputW + labelW, rowH);
- y += rowH + 5;
-
- this.drawLabel(ctx, "SUPERSAMPLING", pad, y, labelW, rowH);
- this.drawBooleanField(ctx, this.data.use_supersampling, pad + labelW, y, inputW, rowH);
- this.widgetYPositions["use_supersampling"] = y;
- this.clickZones.push({ type: "boolean", field: "use_supersampling", x: pad + labelW, y: y, w: inputW, h: rowH });
- y += rowH + 5;
-
- if (this.data.use_supersampling) {
- this.drawLabel(ctx, "SUPERSAMPLE FACTOR", pad, y, labelW, rowH);
- this.drawNumberFieldWithArrows(ctx, this.data.supersampling_factor, pad + labelW, y, inputW, rowH, arrowW);
- this.widgetYPositions["supersampling_factor"] = y;
- this.clickZones.push({ type: "number", field: "supersampling_factor", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 2, max: 4 });
- this.clickZones.push({ type: "arrow_left", field: "supersampling_factor", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 2, max: 4, step: 1 });
- this.clickZones.push({ type: "arrow_right", field: "supersampling_factor", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 2, max: 4, step: 1 });
- y += rowH + 5;
- }
-
- this.drawLabel(ctx, "EDGE SMOOTHING", pad, y, labelW, rowH);
- this.drawBooleanField(ctx, this.data.use_edge_smoothing, pad + labelW, y, inputW, rowH);
- this.widgetYPositions["use_edge_smoothing"] = y;
- this.clickZones.push({ type: "boolean", field: "use_edge_smoothing", x: pad + labelW, y: y, w: inputW, h: rowH });
- y += rowH + 5;
-
- 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(e); return true; }
- if (zone.type === "combo") { self.showComboSelector(zone.field, e); return true; }
- if (zone.type === "color") { self.openColorPicker(zone.field, e); return true; }
- if (zone.type === "boolean") { self.data[zone.field] = !self.data[zone.field]; self.updateUI(); return true; }
- if (zone.type === "number") {
- self.showInlineInput(zone.field, e, { isNumber: true, min: zone.min, max: zone.max, step: zone.step || 1, float: zone.float || false });
- 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";
- if (!value || value.trim() === "") { ctx.fillStyle = "#666"; ctx.fillText("Enter text...", x + 5, y + h/2 + 4); return; }
- const lines = value.split('\n'), lineHeight = 14, startY = y + 10, maxLines = 3;
- for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
- let line = lines[i]; if (line.length > 35) line = line.substring(0, 32) + "...";
- ctx.fillText(line, x + 5, startY + (i * lineHeight));
- }
- if (lines.length > maxLines) { ctx.fillStyle = "#666"; ctx.fillText("...", x + 5, startY + (maxLines * lineHeight)); }
- };
- 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, colorBoxY = y, colorBoxH = h;
- 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, 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, clickEvent) {
- const options = { font_name: self.data.font_list || ["default"] };
- let 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 = (ev) => { ev.stopPropagation(); ev.preventDefault(); self.data[fieldName] = opt; self.updateUI(); menu.remove(); };
- menu.appendChild(item);
- });
- if (clickEvent) { menu.style.left = (clickEvent.clientX + 8) + "px"; menu.style.top = clickEvent.clientY + "px"; }
- else { menu.style.left = "250px"; menu.style.top = "200px"; }
- document.body.appendChild(menu);
- setTimeout(() => { const closeHandler = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener("mousedown", closeHandler); } }; document.addEventListener("mousedown", closeHandler); }, 100);
- };
- this.showTextInput = function(clickEvent) {
- 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 = 'SAVE';
- 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);
- if (clickEvent) { popup.style.left = (clickEvent.clientX + 8) + 'px'; popup.style.top = clickEvent.clientY + 'px'; }
- document.body.appendChild(popup);
- setTimeout(() => { input.focus(); setTimeout(() => { if (currentValue && currentValue.length > 0) input.select(); }, 10); }, 50);
- saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); self.data.text = input.value; self.updateUI(); popup.remove(); };
- input.onkeydown = (ev) => { if (ev.key === 'Enter' && ev.ctrlKey) { ev.preventDefault(); self.data.text = input.value; self.updateUI(); popup.remove(); } };
- };
- this.showInlineInput = function(fieldName, clickEvent, options = {}) {
- const currentValue = self.data[fieldName] || '';
- const isNumber = options.isNumber || false, min = options.min !== undefined ? options.min : null, max = options.max !== undefined ? options.max : null, isFloat = options.float || false;
- const popup = document.createElement('div');
- popup.style.cssText = `position:fixed;z-index:10003;background:#1a1a1a;border:1px solid #444;border-radius:6px;padding:8px 12px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;align-items:center;gap:8px;white-space:nowrap;`;
- const input = document.createElement('input'); input.type = isNumber ? 'number' : 'text'; input.value = currentValue;
- if (isNumber) { input.step = options.step || 1; if (min !== null) input.min = min; if (max !== null) input.max = max; }
- input.style.cssText = `width:100px;background:#222;color:#fff;border:1px solid #444;border-radius:4px;padding:6px 10px;font-size:12px;outline:none;`;
- const saveBtn = document.createElement('button'); saveBtn.textContent = 'OK'; saveBtn.title = 'Save';
- saveBtn.style.cssText = `background:#4CAF50;color:#fff;border:none;border-radius:4px;padding:6px 12px;font-size:12px;cursor:pointer;min-width:28px;`;
- saveBtn.onmouseover = () => saveBtn.style.background = "#45a049"; saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50";
- popup.appendChild(input); popup.appendChild(saveBtn);
- if (clickEvent) { popup.style.left = (clickEvent.clientX + 8) + 'px'; popup.style.top = clickEvent.clientY + 'px'; }
- document.body.appendChild(popup);
- setTimeout(() => { input.focus(); setTimeout(() => { if (currentValue !== null && currentValue !== undefined && String(currentValue).length > 0) input.select(); }, 10); }, 50);
- const doSave = () => { let value = input.value; if (isNumber) { let num = isFloat ? parseFloat(value) : parseInt(value); if (isNaN(num)) num = self.data[fieldName]; if (min !== null) num = Math.max(min, num); if (max !== null) num = Math.min(max, num); value = isFloat ? Math.round(num * 100) / 100 : num; } self.data[fieldName] = value; self.updateUI(); popup.remove(); };
- saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); doSave(); };
- input.onkeydown = (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); doSave(); } };
- setTimeout(() => { const closeHandler = (ev) => { if (!popup.contains(ev.target)) { popup.remove(); document.removeEventListener("mousedown", closeHandler); } }; document.addEventListener("mousedown", closeHandler); }, 50);
- return popup;
- };
- this.openColorPicker = function(fieldName, clickEvent) {
- const currentColor = self.data[fieldName] || '#FFFFFF';
- const colorInput = document.createElement('input'); colorInput.type = 'color'; colorInput.value = currentColor; colorInput.style.display = 'none';
- document.body.appendChild(colorInput);
- colorInput.addEventListener('change', (e) => { const newColor = e.target.value.toUpperCase(); self.data[fieldName] = newColor; self.updateUI(); colorInput.remove(); }, { once: true });
- setTimeout(() => { if (colorInput.showPicker) colorInput.showPicker(); else colorInput.click(); }, 10);
- };
-
- 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;
- };
- }
- }
-});
\ No newline at end of file