From 4466cb2c1f54da3a2ff4b1f79f09c046a74aeba9 Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:10:40 +0300 Subject: [PATCH] Fix settings --- Rayko_Text_Overlay.py | 110 ++++++++++---- web/rayko_save_image.js | 241 ++++++++++++++++++++++-------- web/rs_text_overlay.js | 317 +++++++++++++++++++++++++--------------- 3 files changed, 457 insertions(+), 211 deletions(-) diff --git a/Rayko_Text_Overlay.py b/Rayko_Text_Overlay.py index b7f8f43..46f6de3 100644 --- a/Rayko_Text_Overlay.py +++ b/Rayko_Text_Overlay.py @@ -49,6 +49,7 @@ class TextRenderConfig: shadow_color: Tuple[int, int, int, int] shadow_offset: Tuple[int, int] shadow_blur: float + shadow_opacity: float text_opacity: int = 100 use_supersampling: bool = False use_edge_smoothing: bool = True @@ -60,7 +61,7 @@ class TextRenderConfig: @property def has_shadow(self) -> bool: - return self.enable_shadow and any(self.shadow_offset) + return self.enable_shadow class LRUCache: def __init__(self, max_size: int = 100): @@ -105,7 +106,8 @@ class HighQualityTextRenderer: shadow_color=config.shadow_color, shadow_offset=(config.shadow_offset[0] * scale_factor, config.shadow_offset[1] * scale_factor), - shadow_blur=config.shadow_blur, + shadow_blur=config.shadow_blur * scale_factor, + shadow_opacity=config.shadow_opacity, text_opacity=config.text_opacity, use_supersampling=False, use_edge_smoothing=False @@ -613,6 +615,7 @@ class RS_TextOverlay: self.log(f"Error calculating mask rotation: {e}", level=2) return 0.0 + # ✅ ИСПРАВЛЕННАЯ ФУНКЦИЯ — обводка с letter_spacing def _draw_single_line(self, draw, position, text, font, color, letter_spacing, text_orientation): x, y = position @@ -628,19 +631,44 @@ class RS_TextOverlay: bbox = draw.textbbox((x, current_y), char, font=font) current_y += (bbox[3] - bbox[1]) + letter_spacing - def _draw_outline_optimized(self, draw, position, text, font, color, thickness): + # ✅ ИСПРАВЛЕННАЯ ФУНКЦИЯ — обводка рисуется для КАЖДОЙ буквы с letter_spacing + def _draw_outline_optimized(self, draw, position, text, font, color, thickness, letter_spacing, text_orientation): x, y = position - if thickness == 1: - directions = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)] - for dx, dy in directions: - self._draw_single_line(draw, (x + dx, y + dy), text, font, color, 0, "horizontal") + if text_orientation == "horizontal": + current_x = x + for char in text: + # Рисуем обводку для каждой буквы + if thickness == 1: + directions = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)] + for dx, dy in directions: + draw.text((current_x + dx, y + dy), char, font=font, fill=color) + else: + for radius in range(1, thickness + 1): + for angle in range(0, 360, 45): + dx = int(radius * math.cos(math.radians(angle))) + dy = int(radius * math.sin(math.radians(angle))) + draw.text((current_x + dx, y + dy), char, font=font, fill=color) + + # ✅ Применяем letter_spacing к обводке + current_x += draw.textlength(char, font=font) + letter_spacing else: - for radius in range(1, thickness + 1): - for angle in range(0, 360, 45): - dx = int(radius * math.cos(math.radians(angle))) - dy = int(radius * math.sin(math.radians(angle))) - self._draw_single_line(draw, (x + dx, y + dy), text, font, color, 0, "horizontal") + current_y = y + for char in text: + # Рисуем обводку для каждой буквы + if thickness == 1: + directions = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)] + for dx, dy in directions: + draw.text((x + dx, current_y + dy), char, font=font, fill=color) + else: + for radius in range(1, thickness + 1): + for angle in range(0, 360, 45): + dx = int(radius * math.cos(math.radians(angle))) + dy = int(radius * math.sin(math.radians(angle))) + draw.text((x + dx, current_y + dy), char, font=font, fill=color) + + bbox = draw.textbbox((x, current_y), char, font=font) + current_y += (bbox[3] - bbox[1]) + letter_spacing def _render_text_core(self, text: str, config: TextRenderConfig, container_size: Tuple[int, int]) -> Image.Image: @@ -664,9 +692,11 @@ class RS_TextOverlay: start_x = (container_size[0] - line_w) // 2 + # ✅ ИСПРАВЛЕНИЕ: передаём letter_spacing в обводку if config.has_outline: self._draw_outline_optimized(draw, (start_x, current_y), line, config.font, - config.outline_color, config.outline_thickness) + config.outline_color, config.outline_thickness, + config.letter_spacing, config.text_orientation) self._draw_single_line(draw, (start_x, current_y), line, config.font, config.text_color, config.letter_spacing, config.text_orientation) @@ -685,14 +715,16 @@ class RS_TextOverlay: text_layer = self._render_text_core(text, config, container_size) - if config.has_shadow: - text_layer = self._apply_shadow_effect(text_layer, config) - + # Сначала supersampling 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_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) @@ -700,23 +732,48 @@ class RS_TextOverlay: return text_layer 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, config.shadow_color) - shadow.putalpha(alpha) + # Создаём тень с альфа-каналом текста + 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 - if offset_x or offset_y: - shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y), - Image.Resampling.BICUBIC, fill=(0, 0, 0, 0)) - + shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y), + Image.Resampling.BICUBIC, fill=(0, 0, 0, 0)) + + # Применяем цвет и opacity тени + shadow_data = np.array(shadow) + mask = shadow_data[:, :, 3] > 0 + + # Применяем shadow_opacity к альфа-каналу + shadow_data[:, :, 3] = (shadow_data[:, :, 3].astype(np.float32) * config.shadow_opacity).astype(np.uint8) + + # Создаём цвет тени (RGB без alpha) + 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, (0, 0), shadow) + result.paste(shadow_colored, (0, 0), shadow_colored) result.paste(text_layer, (0, 0), text_layer) + return result def _process_single(self, image, mask, text, font_name, config, padding, @@ -825,6 +882,8 @@ class RS_TextOverlay: max_font_size = data.get('max_font_size', 500) padding = data.get('padding', 5) + self.log(f"Shadow settings: enable={enable_shadow}, color={shadow_color}, offset=({shadow_offset_x}, {shadow_offset_y}), blur={shadow_blur}, opacity={shadow_opacity}", level=1) + config = TextRenderConfig( font=None, text_color=self.parse_color(text_color, text_opacity), @@ -834,9 +893,10 @@ class RS_TextOverlay: line_spacing=line_spacing, text_orientation=text_orientation, enable_shadow=enable_shadow, - shadow_color=self.parse_color(shadow_color, shadow_opacity), + 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, text_opacity=int(text_opacity * 100), use_supersampling=use_supersampling, use_edge_smoothing=use_edge_smoothing, diff --git a/web/rayko_save_image.js b/web/rayko_save_image.js index 7728fba..43bbf10 100644 --- a/web/rayko_save_image.js +++ b/web/rayko_save_image.js @@ -10,11 +10,9 @@ app.registerExtension({ nodeType.prototype.onNodeCreated = function() { const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; - // Отключаем стандартное превью ComfyUI this.allow_preview = false; this._outputNode = false; - // Дефолтные данные интерфейса this.data = { text: "", font_name: "default", @@ -34,7 +32,6 @@ app.registerExtension({ this.widgetsHeight = 260; this.previewHeight = 0; - // === ПРЕВЮ ИЗОБРАЖЕНИЙ === this.imgs = []; this.imageIndex = 0; @@ -60,11 +57,9 @@ app.registerExtension({ this.widgets.forEach(w => w.hidden = true); } - // Устанавливаем начальный размер ноды this.setSize([this.targetWidth, 450]); const self = this; - // Обработка полученных изображений const onExecuted = this.onExecuted; this.onExecuted = function(message) { const r = onExecuted ? onExecuted.apply(this, arguments) : undefined; @@ -117,9 +112,7 @@ app.registerExtension({ } }; - // === ПОЛНОСТЬЮ ПЕРЕОПРЕДЕЛЯЕМ onDrawBackground === this.onDrawBackground = function(ctx) { - // Рисуем превью ниже виджетов if (this.imgs.length > 0 && this.imgs[this.imageIndex]) { const img = this.imgs[this.imageIndex]; const previewY = this.widgetsHeight + 20; @@ -135,24 +128,20 @@ app.registerExtension({ drawWidth = maxPreviewHeight / aspectRatio; } - // === ЦЕНТРИРОВАНИЕ ПО ГОРИЗОНТАЛИ === const previewX = (this.size[0] - drawWidth) / 2; - // Рамка превью ctx.fillStyle = "#1a1a1a"; ctx.fillRect(previewX - 5, previewY - 5, drawWidth + 10, drawHeight + 10); ctx.strokeStyle = "#444"; ctx.lineWidth = 1; ctx.strokeRect(previewX - 5, previewY - 5, drawWidth + 10, drawHeight + 10); - // Рисуем изображение try { ctx.drawImage(img, previewX, previewY, drawWidth, drawHeight); } catch (e) { console.error("Error drawing preview:", e); } - // Индикатор количества изображений if (this.imgs.length > 1) { ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillRect(previewX + drawWidth - 60, previewY + drawHeight - 25, 55, 20); @@ -164,10 +153,8 @@ app.registerExtension({ } }; - // === ОТРИСОВКА НА ПЕРЕДНЕМ ПЛАНЕ (виджеты) === const onDrawForeground = this.onDrawForeground; this.onDrawForeground = function(ctx, visibleRect) { - // Вызываем оригинальную отрисовку переднего плана (для заголовка, рамок и т.д.) if (onDrawForeground) { onDrawForeground.apply(this, arguments); } @@ -181,21 +168,21 @@ app.registerExtension({ const inputW = this.size[0] - pad*2 - labelW; let y = startY; - // === TEXT === + // TEXT this.drawLabel(ctx, "TEXT", pad, y, labelW, rowH * 2); this.drawMultilineField(ctx, this.data.text, pad + labelW, y, inputW, rowH * 2); this.clickZones.push({ type: "text", x: pad + labelW, y: y, w: inputW, h: rowH * 2 }); this.widgetYPositions["text"] = y; y += rowH * 2 + 10; - // === FONT === + // FONT 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; - // === FONT SIZE === + // FONT SIZE this.drawLabel(ctx, "FONT SIZE", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.font_size, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["font_size"] = y; @@ -204,7 +191,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "font_size", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 1, max: 512, step: 1 }); y += rowH + 5; - // === FOOTER SIZE === + // FOOTER SIZE this.drawLabel(ctx, "FOOTER SIZE", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.footer_height, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["footer_height"] = y; @@ -213,14 +200,14 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "footer_height", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1024, step: 1 }); y += rowH + 5; - // === THEME === + // THEME this.drawLabel(ctx, "THEME", pad, y, labelW, rowH); this.drawComboField(ctx, this.data.theme, pad + labelW, y, inputW, rowH); this.widgetYPositions["theme"] = y; this.clickZones.push({ type: "combo", field: "theme", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === PREFIX === + // PREFIX this.drawLabel(ctx, "PREFIX", pad, y, labelW, rowH); this.drawStringField(ctx, this.data.filename_prefix, pad + labelW, y, inputW, rowH); this.widgetYPositions["filename_prefix"] = y; @@ -233,7 +220,7 @@ app.registerExtension({ this.onMouseDown = function(e, pos, canvas) { if (!this.clickZones.length) return false; - // Проверка клика по превью для переключения изображений + // Клик по превью для переключения изображений if (this.imgs.length > 1 && this.imgs[this.imageIndex]) { const img = this.imgs[this.imageIndex]; const previewY = this.widgetsHeight + 20; @@ -247,7 +234,6 @@ app.registerExtension({ drawWidth = maxPreviewHeight / aspectRatio; } - // === ЦЕНТРИРОВАНИЕ ПО ГОРИЗОНТАЛИ (для клика) === const previewX = (this.size[0] - drawWidth) / 2; if (pos[0] >= previewX + drawWidth - 60 && pos[0] <= previewX + drawWidth && @@ -265,26 +251,32 @@ app.registerExtension({ const inY = pos[1] >= zone.y && pos[1] <= zone.y + zone.h; if (inX && inY) { if (zone.type === "text") { - self.showTextInput(); + self.showTextInput(e); return true; } if (zone.type === "string") { - self.showStringInput(zone.field); + self.showInlineInput(zone.field, e); return true; } if (zone.type === "combo") { - self.showComboSelector(zone.field); + self.showComboSelector(zone.field, e); return true; } if (zone.type === "number") { - const current = self.data[zone.field]; - const newVal = prompt(`${zone.field}:`, current); - if (newVal !== null) { - let parsed = parseInt(newVal); - if (!isNaN(parsed)) { - parsed = Math.max(zone.min, Math.min(zone.max, parsed)); - self.data[zone.field] = parsed; - self.updateUI(); + if (zone.field === "font_size") { + self.showInlineInput(zone.field, e, { isNumber: true, min: 1, max: 512, step: 1 }); + } else if (zone.field === "footer_height") { + self.showInlineInput(zone.field, e, { isNumber: true, min: 0, max: 1024, step: 1 }); + } else { + const current = self.data[zone.field]; + const newVal = prompt(`${zone.field}:`, current); + if (newVal !== null) { + let parsed = parseInt(newVal); + if (!isNaN(parsed)) { + parsed = Math.max(zone.min, Math.min(zone.max, parsed)); + self.data[zone.field] = parsed; + self.updateUI(); + } } } return true; @@ -308,6 +300,7 @@ app.registerExtension({ return false; }; + // === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ОТРИСОВКИ === this.drawLabel = function(ctx, text, x, y, w, h) { ctx.fillStyle = "#aaa"; ctx.font = "11px sans-serif"; @@ -315,7 +308,6 @@ app.registerExtension({ 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); @@ -331,23 +323,19 @@ app.registerExtension({ return; } - // Разбиваем текст на строки и рисуем каждую const lines = value.split('\n'); const lineHeight = 14; const startY = y + 10; - - // Рисуем максимум 3 строки для отображения const 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)); @@ -407,7 +395,8 @@ app.registerExtension({ ctx.fillText(value, x + w/2, y + h/2 + 4); }; - this.showComboSelector = function(fieldName) { + // === POPUP: ВЫПАДАЮЩИЙ СПИСОК (FONT, THEME) === + this.showComboSelector = function(fieldName, clickEvent) { let list = []; if (fieldName === "font_name") { list = self.data.font_list || ["default"]; @@ -442,9 +431,9 @@ app.registerExtension({ `; item.onmouseover = () => item.style.background = "#333"; item.onmouseout = () => item.style.background = "#1a1a1a"; - item.onclick = (e) => { - e.stopPropagation(); - e.preventDefault(); + item.onclick = (ev) => { + ev.stopPropagation(); + ev.preventDefault(); self.data[fieldName] = opt; self.updateUI(); menu.remove(); @@ -452,15 +441,9 @@ app.registerExtension({ menu.appendChild(item); }); - const widgetY = self.widgetYPositions[fieldName] || 200; - const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect() || - document.querySelector("canvas")?.getBoundingClientRect(); - - if (canvasRect && self.pos) { - const menuX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; - const menuY = canvasRect.top + self.pos[1] + widgetY + self.rowHeight; - menu.style.left = menuX + "px"; - menu.style.top = menuY + "px"; + if (clickEvent) { + menu.style.left = (clickEvent.clientX + 8) + "px"; + menu.style.top = clickEvent.clientY + "px"; } else { menu.style.left = "250px"; menu.style.top = "200px"; @@ -469,8 +452,8 @@ app.registerExtension({ document.body.appendChild(menu); setTimeout(() => { - const closeHandler = (e) => { - if (!menu.contains(e.target)) { + const closeHandler = (ev) => { + if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener("mousedown", closeHandler); } @@ -479,7 +462,8 @@ app.registerExtension({ }, 100); }; - this.showTextInput = function() { + // === POPUP: МНОГОСТРОЧНЫЙ ТЕКСТ (TEXT) — С ИСПРАВЛЕННЫМ ВЫДЕЛЕНИЕМ === + this.showTextInput = function(clickEvent) { const currentValue = self.data.text || ''; const popup = document.createElement('div'); @@ -527,27 +511,154 @@ app.registerExtension({ popup.appendChild(input); popup.appendChild(saveBtn); - const widgetY = self.widgetYPositions["text"] || 80; - const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect(); - if (canvasRect && self.pos) { - const inputX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; - const inputY = canvasRect.top + self.pos[1] + widgetY + (self.rowHeight * 2); - popup.style.left = inputX + 'px'; - popup.style.top = inputY + 'px'; + if (clickEvent) { + popup.style.left = (clickEvent.clientX + 8) + 'px'; + popup.style.top = clickEvent.clientY + 'px'; } document.body.appendChild(popup); - input.focus(); + + // === ИСПРАВЛЕННЫЙ ФОКУС И ВЫДЕЛЕНИЕ — ДВОЙНАЯ ЗАДЕРЖКА === + setTimeout(() => { + input.focus(); + setTimeout(() => { + if (currentValue && currentValue.length > 0) { + input.select(); + } + }, 10); + }, 50); - saveBtn.onclick = (e) => { - e.stopPropagation(); - e.preventDefault(); + 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(); + } + }; }; + // === POPUP: ОДНОСТРОЧНЫЙ ВВОД — С ИСПРАВЛЕННЫМ ВЫДЕЛЕНИЕМ ДЛЯ ВСЕХ ПОЛЕЙ === + this.showInlineInput = function(fieldName, clickEvent, options = {}) { + const currentValue = self.data[fieldName] || ''; + const isNumber = options.isNumber || false; + const min = options.min !== undefined ? options.min : null; + const max = options.max !== undefined ? options.max : null; + + 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 = '✅'; + 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 && currentValue.toString().length > 0) { + // === ТЕПЕРЬ select() ДЛЯ ВСЕХ ПОЛЕЙ, ВКЛЮЧАЯ ЧИСЛОВЫЕ === + input.select(); + } + }, 10); + }, 50); + + const doSave = () => { + let value = input.value; + if (isNumber) { + let num = 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 = 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.showStringInput = function(fieldName) { const currentValue = self.data[fieldName] || ''; const newVal = prompt(`Enter ${fieldName}:`, currentValue); diff --git a/web/rs_text_overlay.js b/web/rs_text_overlay.js index 4670a1d..7fe9dd1 100644 --- a/web/rs_text_overlay.js +++ b/web/rs_text_overlay.js @@ -83,28 +83,28 @@ app.registerExtension({ const inputW = this.size[0] - pad*2 - labelW; let y = startY; - // === TEXT === + // TEXT this.drawLabel(ctx, "TEXT", pad, y, labelW, rowH * 2); this.drawMultilineField(ctx, this.data.text, pad + labelW, y, inputW, rowH * 2); this.clickZones.push({ type: "text", x: pad + labelW, y: y, w: inputW, h: rowH * 2 }); this.widgetYPositions["text"] = y; y += rowH * 2 + 5; - // === FONT NAME === + // FONT this.drawLabel(ctx, "FONT", pad, y, labelW, rowH); this.drawComboField(ctx, this.data.font_name, pad + labelW, y, inputW, rowH); this.widgetYPositions["font_name"] = y; this.clickZones.push({ type: "combo", field: "font_name", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === TEXT COLOR === + // TEXT COLOR this.drawLabel(ctx, "TEXT COLOR", pad, y, labelW, rowH); this.drawColorField(ctx, this.data.text_color, pad + labelW, y, inputW, rowH); this.widgetYPositions["text_color"] = y; this.clickZones.push({ type: "color", field: "text_color", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === OUTLINE THICKNESS === + // OUTLINE THICKNESS this.drawLabel(ctx, "OUTLINE THICK", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.outline_thickness, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["outline_thickness"] = y; @@ -113,25 +113,25 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "outline_thickness", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 50, step: 1 }); y += rowH + 5; - // === OUTLINE COLOR === + // OUTLINE COLOR this.drawLabel(ctx, "OUTLINE COLOR", pad, y, labelW, rowH); this.drawColorField(ctx, this.data.outline_color, pad + labelW, y, inputW, rowH); this.widgetYPositions["outline_color"] = y; this.clickZones.push({ type: "color", field: "outline_color", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === FORMATTING TEXT SEPARATOR === + // FORMATTING TEXT SEPARATOR this.drawSeparator(ctx, "FORMATTING TEXT", pad, y, inputW + labelW, rowH); y += rowH + 5; - // === ROTATE WITH MASK === + // ROTATE WITH MASK this.drawLabel(ctx, "ROTATE WITH MASK", pad, y, labelW, rowH); this.drawBooleanField(ctx, this.data.rotate_with_mask, pad + labelW, y, inputW, rowH); this.widgetYPositions["rotate_with_mask"] = y; this.clickZones.push({ type: "boolean", field: "rotate_with_mask", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === TEXT OPACITY === + // TEXT OPACITY this.drawLabel(ctx, "TEXT OPACITY", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.text_opacity, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["text_opacity"] = y; @@ -140,7 +140,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "text_opacity", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true }); y += rowH + 5; - // === LINE SPACING === + // LINE SPACING this.drawLabel(ctx, "LINE SPACING", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.line_spacing, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["line_spacing"] = y; @@ -149,7 +149,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "line_spacing", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0.5, max: 3, step: 0.1, float: true }); y += rowH + 5; - // === LETTER SPACING === + // LETTER SPACING this.drawLabel(ctx, "LETTER SPACING", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.letter_spacing, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["letter_spacing"] = y; @@ -158,32 +158,32 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "letter_spacing", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -20, max: 100, step: 0.5, float: true }); y += rowH + 5; - // === TEXT ORIENTATION === + // TEXT ORIENTATION this.drawLabel(ctx, "ORIENTATION", pad, y, labelW, rowH); this.drawComboField(ctx, this.data.text_orientation, pad + labelW, y, inputW, rowH); this.widgetYPositions["text_orientation"] = y; this.clickZones.push({ type: "combo", field: "text_orientation", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === SHADOW SETTINGS SEPARATOR === + // SHADOW SETTINGS SEPARATOR this.drawSeparator(ctx, "SHADOW SETTINGS", pad, y, inputW + labelW, rowH); y += rowH + 5; - // === ENABLE SHADOW === + // ENABLE SHADOW this.drawLabel(ctx, "ENABLE SHADOW", pad, y, labelW, rowH); this.drawBooleanField(ctx, this.data.enable_shadow, pad + labelW, y, inputW, rowH); this.widgetYPositions["enable_shadow"] = y; this.clickZones.push({ type: "boolean", field: "enable_shadow", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === SHADOW COLOR === + // SHADOW COLOR this.drawLabel(ctx, "SHADOW COLOR", pad, y, labelW, rowH); this.drawColorField(ctx, this.data.shadow_color, pad + labelW, y, inputW, rowH); this.widgetYPositions["shadow_color"] = y; this.clickZones.push({ type: "color", field: "shadow_color", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === SHADOW OFFSET X === + // SHADOW OFFSET X this.drawLabel(ctx, "SHADOW OFFSET X", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.shadow_offset_x, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["shadow_offset_x"] = y; @@ -192,7 +192,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "shadow_offset_x", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); y += rowH + 5; - // === SHADOW OFFSET Y === + // SHADOW OFFSET Y this.drawLabel(ctx, "SHADOW OFFSET Y", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.shadow_offset_y, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["shadow_offset_y"] = y; @@ -201,7 +201,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "shadow_offset_y", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); y += rowH + 5; - // === SHADOW BLUR === + // SHADOW BLUR this.drawLabel(ctx, "SHADOW BLUR", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.shadow_blur, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["shadow_blur"] = y; @@ -210,7 +210,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "shadow_blur", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true }); y += rowH + 5; - // === SHADOW OPACITY === + // SHADOW OPACITY this.drawLabel(ctx, "SHADOW OPACITY", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.shadow_opacity, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["shadow_opacity"] = y; @@ -219,18 +219,18 @@ app.registerExtension({ 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; - // === QUALITY SETTINGS SEPARATOR === + // QUALITY SETTINGS SEPARATOR this.drawSeparator(ctx, "QUALITY SETTINGS", pad, y, inputW + labelW, rowH); y += rowH + 5; - // === USE SUPERSAMPLING === + // USE SUPERSAMPLING 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; - // === SUPERSAMPLING FACTOR === + // SUPERSAMPLING FACTOR 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); @@ -241,7 +241,7 @@ app.registerExtension({ y += rowH + 5; } - // === USE EDGE SMOOTHING === + // USE EDGE SMOOTHING 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; @@ -256,22 +256,21 @@ app.registerExtension({ 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(); + self.showTextInput(e); return true; } if (zone.type === "combo") { - console.log("🔽 Combo clicked:", zone.field); - self.showComboSelector(zone.field); + self.showComboSelector(zone.field, e); return true; } if (zone.type === "color") { - console.log("🎨 Color clicked:", zone.field); - self.openColorPicker(zone.field); + self.openColorPicker(zone.field, e); return true; } if (zone.type === "boolean") { @@ -280,16 +279,13 @@ app.registerExtension({ return true; } if (zone.type === "number") { - const current = self.data[zone.field]; - const newVal = prompt(`${zone.field}:`, current); - if (newVal !== null) { - let parsed = zone.float ? parseFloat(newVal) : parseInt(newVal); - if (!isNaN(parsed)) { - parsed = Math.max(zone.min, Math.min(zone.max, parsed)); - self.data[zone.field] = zone.float ? Math.round(parsed * 100) / 100 : parsed; - self.updateUI(); - } - } + 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") { @@ -313,6 +309,7 @@ app.registerExtension({ return false; }; + // === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ОТРИСОВКИ === this.drawLabel = function(ctx, text, x, y, w, h) { ctx.fillStyle = "#aaa"; ctx.font = "11px sans-serif"; @@ -320,7 +317,6 @@ app.registerExtension({ 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); @@ -336,23 +332,19 @@ app.registerExtension({ return; } - // Разбиваем текст на строки и рисуем каждую const lines = value.split('\n'); const lineHeight = 14; const startY = y + 10; - - // Рисуем максимум 3 строки для отображения const 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)); @@ -439,7 +431,8 @@ app.registerExtension({ ctx.fillText(value ? "ON" : "OFF", x + 8, y + h/2 + 4); }; - this.showComboSelector = function(fieldName) { + // === POPUP: ВЫПАДАЮЩИЙ СПИСОК — ПОЗИЦИЯ СПРАВА ОТ КЛИКА === + this.showComboSelector = function(fieldName, clickEvent) { const options = { font_name: self.data.font_list || ["default"], text_orientation: ["horizontal", "vertical"] @@ -473,10 +466,9 @@ app.registerExtension({ `; item.onmouseover = () => item.style.background = "#333"; item.onmouseout = () => item.style.background = "#1a1a1a"; - item.onclick = (e) => { - e.stopPropagation(); - e.preventDefault(); - console.log("✅ Combo selected:", opt); + item.onclick = (ev) => { + ev.stopPropagation(); + ev.preventDefault(); self.data[fieldName] = opt; self.updateUI(); menu.remove(); @@ -484,15 +476,9 @@ app.registerExtension({ menu.appendChild(item); }); - const widgetY = self.widgetYPositions[fieldName] || 200; - const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect() || - document.querySelector("canvas")?.getBoundingClientRect(); - - if (canvasRect && self.pos) { - const menuX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; - const menuY = canvasRect.top + self.pos[1] + widgetY + self.rowHeight; - menu.style.left = menuX + "px"; - menu.style.top = menuY + "px"; + if (clickEvent) { + menu.style.left = (clickEvent.clientX + 8) + "px"; + menu.style.top = clickEvent.clientY + "px"; } else { menu.style.left = "250px"; menu.style.top = "200px"; @@ -501,8 +487,8 @@ app.registerExtension({ document.body.appendChild(menu); setTimeout(() => { - const closeHandler = (e) => { - if (!menu.contains(e.target)) { + const closeHandler = (ev) => { + if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener("mousedown", closeHandler); } @@ -511,7 +497,8 @@ app.registerExtension({ }, 100); }; - this.showTextInput = function() { + // === POPUP: МНОГОСТРОЧНЫЙ ТЕКСТ — С ФОКУСОМ И ВЫДЕЛЕНИЕМ === + this.showTextInput = function(clickEvent) { const currentValue = self.data.text || ''; const popup = document.createElement('div'); @@ -559,89 +546,177 @@ app.registerExtension({ popup.appendChild(input); popup.appendChild(saveBtn); - const widgetY = self.widgetYPositions["text"] || 80; - const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect(); - if (canvasRect && self.pos) { - const inputX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; - const inputY = canvasRect.top + self.pos[1] + widgetY + (self.rowHeight * 2); - popup.style.left = inputX + 'px'; - popup.style.top = inputY + 'px'; + if (clickEvent) { + popup.style.left = (clickEvent.clientX + 8) + 'px'; + popup.style.top = clickEvent.clientY + 'px'; } document.body.appendChild(popup); - input.focus(); + + setTimeout(() => { + input.focus(); + setTimeout(() => { + if (currentValue && currentValue.length > 0) { + input.select(); + } + }, 10); + }, 50); - saveBtn.onclick = (e) => { - e.stopPropagation(); - e.preventDefault(); + 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(); + } + }; + }; + + // === POPUP: ОДНОСТРОЧНЫЙ ВВОД — С ФОКУСОМ И ВЫДЕЛЕНИЕМ === + this.showInlineInput = function(fieldName, clickEvent, options = {}) { + const currentValue = self.data[fieldName] || ''; + const isNumber = options.isNumber || false; + const min = options.min !== undefined ? options.min : null; + const max = options.max !== undefined ? options.max : null; + const 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 = '✅'; + 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) { + // === НАТИВНЫЙ КОЛОРПИКЕР — ПРОСТОЙ И НАДЁЖНЫЙ === + this.openColorPicker = function(fieldName, clickEvent) { const currentColor = self.data[fieldName] || '#FFFFFF'; - console.log("🎨 Opening color picker:", fieldName, currentColor); + const colorInput = document.createElement('input'); colorInput.type = 'color'; colorInput.value = currentColor; colorInput.style.display = 'none'; document.body.appendChild(colorInput); + colorInput.addEventListener('change', (e) => { - e.stopPropagation(); - e.preventDefault(); const newColor = e.target.value.toUpperCase(); self.data[fieldName] = newColor; - console.log("🎨 Color changed:", newColor); self.updateUI(); - }, { once: false }); + colorInput.remove(); + }, { once: true }); - const widgetY = self.widgetYPositions[fieldName] || 200; - const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect() || - document.querySelector("canvas")?.getBoundingClientRect(); - - if (canvasRect && self.pos) { - const pickerX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; - const pickerY = canvasRect.top + self.pos[1] + widgetY; - colorInput.style.position = 'fixed'; - colorInput.style.left = pickerX + 'px'; - colorInput.style.top = pickerY + 'px'; - colorInput.style.zIndex = '10002'; - colorInput.style.width = '1px'; - colorInput.style.height = '1px'; - colorInput.style.opacity = '0'; - console.log("📍 Picker positioned at:", pickerX, pickerY); - requestAnimationFrame(() => { - if (colorInput.showPicker) { - colorInput.showPicker(); - } else { - colorInput.click(); - } - }); - } else { - colorInput.style.position = 'fixed'; - colorInput.style.left = (window.innerWidth / 2) + 'px'; - colorInput.style.top = (window.innerHeight / 2) + 'px'; - colorInput.style.zIndex = '10002'; - requestAnimationFrame(() => { - if (colorInput.showPicker) { - colorInput.showPicker(); - } else { - colorInput.click(); - } - }); - } - - const clickOutsideHandler = (e) => { - if (e.target !== colorInput) { - colorInput.remove(); - document.removeEventListener("mousedown", clickOutsideHandler); - } - }; + // Открываем нативный пикер setTimeout(() => { - document.addEventListener("mousedown", clickOutsideHandler); - }, 200); + if (colorInput.showPicker) { + colorInput.showPicker(); + } else { + colorInput.click(); + } + }, 10); }; this.syncData = function() {