From f23b97764cd97abb92aafda32ce9d9cd10dc985e Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:26:02 +0300 Subject: [PATCH] Upgrading the RS Color Picker node --- Rayko_Color_Picker.py | 91 +++++- __init__.py | 2 +- package.json | 2 +- pyproject.toml | 2 +- web/rs_colorpicker.js | 628 +++++++++++++++++++++++++++++++++++------- 5 files changed, 620 insertions(+), 105 deletions(-) diff --git a/Rayko_Color_Picker.py b/Rayko_Color_Picker.py index c352812..e600b14 100644 --- a/Rayko_Color_Picker.py +++ b/Rayko_Color_Picker.py @@ -13,12 +13,26 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json +import os +import server +from aiohttp import web + +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +PRESETS_DIR = os.path.join(CURRENT_DIR, "preset_colors") + +if not os.path.exists(PRESETS_DIR): + os.makedirs(PRESETS_DIR) + class RSColorPicker: @classmethod def INPUT_TYPES(cls): return { "required": { - "color": ("STRING", {"default": "#ff0000"}), + "node_data": ("STRING", { + "default": "{}", + "hidden": True + }), }, } @@ -28,7 +42,13 @@ class RSColorPicker: CATEGORY = "🦊 RaykoStudio" DESCRIPTION = "Professional color picker node with advanced features including eyedropper and color history" - def get_color(self, color): + def get_color(self, node_data="{}"): + try: + data = json.loads(node_data) if node_data else {} + color = data.get("color", "#ff0000") + except Exception: + color = "#ff0000" + hex_value = color.lstrip('#') if len(hex_value) > 6: hex_value = hex_value[:6] @@ -46,6 +66,73 @@ class RSColorPicker: return (int_value, hex_str, rgb_str) +@server.PromptServer.instance.routes.post("/rs_colorpicker_save_preset") +async def rs_colorpicker_save_preset(request): + try: + data = await request.json() + name = data.get("name", "").strip() + if not name: + return web.json_response({"error": "Name required"}, status=400) + + name = "".join(c for c in name if c.isalnum() or c in " _-").strip() + if not name: + return web.json_response({"error": "Invalid name"}, status=400) + + filepath = os.path.join(PRESETS_DIR, f"{name}.json") + preset_data = { + "color": data.get("color", "#ff0000"), + "history": data.get("history", []) + } + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(preset_data, f, indent=2) + + return web.json_response({"success": True}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@server.PromptServer.instance.routes.get("/rs_colorpicker_list_presets") +async def rs_colorpicker_list_presets(request): + try: + presets = [] + if os.path.exists(PRESETS_DIR): + presets = [f[:-5] for f in os.listdir(PRESETS_DIR) if f.endswith('.json')] + return web.json_response(sorted(presets, key=lambda x: x.lower())) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@server.PromptServer.instance.routes.post("/rs_colorpicker_load_preset") +async def rs_colorpicker_load_preset(request): + try: + data = await request.json() + name = data.get("name") + if not name: + return web.json_response({"error": "Name required"}, status=400) + + filepath = os.path.join(PRESETS_DIR, f"{name}.json") + if os.path.exists(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + return web.json_response(json.load(f)) + return web.json_response({"error": "Preset not found"}, status=404) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@server.PromptServer.instance.routes.post("/rs_colorpicker_delete_preset") +async def rs_colorpicker_delete_preset(request): + try: + data = await request.json() + name = data.get("name") + if not name: + return web.json_response({"error": "Name required"}, status=400) + + filepath = os.path.join(PRESETS_DIR, f"{name}.json") + if os.path.exists(filepath): + os.remove(filepath) + return web.json_response({"success": True}) + return web.json_response({"error": "Preset not found"}, status=404) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + NODE_CLASS_MAPPINGS = { "RSColorPicker": RSColorPicker } diff --git a/__init__.py b/__init__.py index 9212918..7739cd8 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.31.2" +__version__ = "0.31.3" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index 55a3eab..d087526 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.31.2", + "version": "0.31.3", "description": "Set of custom nodes for ComfyUI providing additional image processing capabilities", "author": "Raykosan", "license": "Apache License 2.0", diff --git a/pyproject.toml b/pyproject.toml index c1f772a..8801542 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.31.2" +version = "0.31.3" license = { file = "LICENSE" } dependencies = ["torch", "numpy", "Pillow>=10.0.0", "freetype-py>=2.5.1", "pycairo>=1.29.0", "scipy>=1.10.0"] diff --git a/web/rs_colorpicker.js b/web/rs_colorpicker.js index 32092ee..91524df 100644 --- a/web/rs_colorpicker.js +++ b/web/rs_colorpicker.js @@ -1,11 +1,10 @@ import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; const NODE_CLASS = "RSColorPicker"; const NODE_W = 200; -const NODE_H = 260; -const NODE_CHROME_H = 32; +const NODE_H = 300; const PRESETS = ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff", "#ffffff", "#000000"]; -const HISTORY_KEY = "rs_colorpicker_history"; function mkEl(tag, css, extra) { const el = document.createElement(tag); @@ -41,15 +40,68 @@ function normalizeHex(hex) { return '#' + h; } -function getHistory() { - try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } - catch { return []; } -} - -function addToHistory(color) { - const history = getHistory().filter(c => c !== color); - history.unshift(color); - localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, 24))); +function showRaykoToast(message, type = "success", node = null) { + const existingToast = document.getElementById("rayko-toast"); + if (existingToast) existingToast.remove(); + + const toast = document.createElement("div"); + toast.id = "rayko-toast"; + + const bgColor = type === "success" ? "#2a3a2a" : "#3a2a2a"; + const borderColor = type === "success" ? "#4CAF50" : "#f44336"; + const textColor = type === "success" ? "#aaffaa" : "#ffaaaa"; + + toast.style.cssText = ` + position: fixed; + background: ${bgColor}; + border: 2px solid ${borderColor}; + border-radius: 6px; + padding: 10px 20px; + color: ${textColor}; + font-size: 13px; + font-weight: bold; + z-index: 10002; + box-shadow: 0 4px 15px rgba(0,0,0,0.5); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + `; + + toast.textContent = message; + document.body.appendChild(toast); + + let posX, posY; + if (node && node.pos && node.size) { + const canvasRect = app.canvas.canvas.getBoundingClientRect(); + const scale = app.canvas.ds.scale; + const offset = app.canvas.ds.offset; + + const nodeScreenX = canvasRect.left + (node.pos[0] + offset[0]) * scale; + const nodeScreenY = canvasRect.top + (node.pos[1] + offset[1]) * scale; + const nodeScreenW = node.size[0] * scale; + const nodeScreenH = node.size[1] * scale; + + posX = nodeScreenX + nodeScreenW / 2; + posY = nodeScreenY + nodeScreenH / 2; + } else { + posX = window.innerWidth / 2; + posY = window.innerHeight / 2; + } + + toast.style.left = posX + "px"; + toast.style.top = posY + "px"; + toast.style.transform = "translate(-50%, -50%)"; + + requestAnimationFrame(() => { + toast.style.opacity = "1"; + }); + + setTimeout(() => { + toast.style.opacity = "0"; + setTimeout(() => { + if (toast.parentNode) toast.remove(); + }, 300); + }, 1500); } const cursorStyle = document.createElement('style'); @@ -99,12 +151,10 @@ class CanvasEyedropper { this.active = true; this.onPick = onPick; document.body.classList.add('eyedropper-active'); - if (buttonRect) { this.magnifier.style.left = (buttonRect.right + 10) + 'px'; this.magnifier.style.top = buttonRect.top + 'px'; } - this.magnifier.style.display = 'block'; document.addEventListener('mousemove', this._onMouseMove); document.addEventListener('click', this._onClick, true); @@ -127,17 +177,10 @@ class CanvasEyedropper { _getColorAt(x, y) { this.magnifier.style.display = 'none'; const el = document.elementFromPoint(x, y); - - if (!el) { + if (!el || this.magnifier.contains(el)) { this.magnifier.style.display = 'block'; return null; } - - if (this.magnifier.contains(el)) { - this.magnifier.style.display = 'block'; - return null; - } - let canvas = el.tagName === 'CANVAS' ? el : el.querySelector('canvas'); if (!canvas) { let parent = el.parentElement; @@ -147,15 +190,12 @@ class CanvasEyedropper { parent = parent.parentElement; } } - this.magnifier.style.display = 'block'; - if (!canvas) return null; const rect = canvas.getBoundingClientRect(); const px = (x - rect.left) * (canvas.width / rect.width); const py = (y - rect.top) * (canvas.height / rect.height); - if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) return null; try { @@ -171,7 +211,6 @@ class CanvasEyedropper { this.magCtx.clearRect(0, 0, 80, 80); this.magCtx.fillStyle = `rgb(${color.r},${color.g},${color.b})`; this.magCtx.fillRect(0, 0, 80, 80); - this.magCtx.strokeStyle = 'rgba(255,255,255,0.3)'; this.magCtx.lineWidth = 1; for (let i = 0; i <= 80; i += 10) { @@ -180,21 +219,17 @@ class CanvasEyedropper { this.magCtx.moveTo(0, i); this.magCtx.lineTo(80, i); this.magCtx.stroke(); } - this.magCtx.strokeStyle = '#fff'; this.magCtx.lineWidth = 1; this.magCtx.strokeRect(35.5, 35.5, 9, 9); - const hex = rgbToHex(color.r, color.g, color.b); this.magLabel.textContent = hex; - this.magnifier.style.left = (x + 10) + 'px'; this.magnifier.style.top = (y + 10) + 'px'; } _onMouseMove(e) { if (this.rafId) return; - this.rafId = requestAnimationFrame(() => { this.rafId = null; const color = this._getColorAt(e.clientX, e.clientY); @@ -210,20 +245,15 @@ class CanvasEyedropper { _onClick(e) { e.preventDefault(); e.stopPropagation(); - const color = this._getColorAt(e.clientX, e.clientY); this.stop(); - - if (color) { - const hex = rgbToHex(color.r, color.g, color.b); - if (this.onPick) this.onPick(hex); + if (color && this.onPick) { + this.onPick(rgbToHex(color.r, color.g, color.b)); } } _onKeyDown(e) { - if (e.key === 'Escape') { - this.stop(); - } + if (e.key === 'Escape') this.stop(); } } @@ -238,6 +268,7 @@ app.registerExtension({ nodeType.output_name = ["HEX_INT", "HEX_STR", "RGB"]; const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { const result = origOnNodeCreated?.apply(this, arguments); const node = this; @@ -245,44 +276,100 @@ app.registerExtension({ node.computeSize = () => [NODE_W, NODE_H]; node.setSize([NODE_W, NODE_H]); + node.data = { + color: "#ff0000", + history: [] + }; + + const hiddenWidget = node.widgets?.find(w => w.name === "node_data"); + if (hiddenWidget) { + hiddenWidget.hidden = true; + hiddenWidget.tooltip = ""; + hiddenWidget.type = "hidden"; + + if (hiddenWidget.element) { + hiddenWidget.element.style.display = "none"; + hiddenWidget.element.style.pointerEvents = "none"; + } + + try { + const savedData = JSON.parse(hiddenWidget.value || "{}"); + if (savedData && typeof savedData === 'object') { + node.data = { + ...node.data, + ...savedData, + history: savedData.history || [] + }; + } + } catch (e) {} + + hiddenWidget.serializeValue = () => { + node.syncData(); + return JSON.stringify(node.data); + }; + } + + node.syncData = function() { + if (hiddenWidget) { + hiddenWidget.value = JSON.stringify(node.data); + } + if (node.graph) { + node.graph.changeTracker?.dispatchEvent(new Event("change")); + } + }; + + node.updateUI = function() { + node.syncData(); + if (node.graph) { + node.graph.setDirtyCanvas(true, true); + node.graph.changeTracker?.dispatchEvent(new Event("change")); + } + }; + const colorWidget = node.widgets?.find(w => w.name === "color"); - let initialColor = "#ff0000"; if (colorWidget) { - initialColor = colorWidget.value || "#ff0000"; colorWidget.hidden = true; colorWidget.computeSize = () => [0, 0]; } - initialColor = normalizeHex(initialColor); - const root = mkEl("div", "display:flex;flex-direction:column;gap:4px;padding:3px;overflow:hidden;"); + const root = mkEl("div", "display:flex;flex-direction:column;gap:4px;padding:2px;overflow:hidden;"); - const basicLabel = mkEl("div", "color:#999;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px;padding-left:2px;", { - textContent: "Basic Colors" - }); + const basicLabel = mkEl("div", "color:#999;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px;padding-left:2px;", { textContent: "Basic Colors" }); const presetsRow = mkEl("div", "display:flex;gap:3px;flex-wrap:wrap;"); PRESETS.forEach(color => { const btn = mkEl("div", `width:19px;height:19px;border-radius:3px;background:${color};cursor:pointer;border:1px solid #333;transition:transform 0.1s;`); btn.onmouseenter = () => btn.style.transform = "scale(1.2)"; btn.onmouseleave = () => btn.style.transform = "scale(1)"; - btn.onclick = () => { - swatch.style.background = color; - colorInput.value = color; - hexInput.value = color.toUpperCase(); - syncOutputs(color); - }; + btn.onclick = () => syncOutputs(color); presetsRow.append(btn); }); - const topRow = mkEl("div", "display:flex;gap:17px;align-items:center;margin-top:5px;"); + const topRow = mkEl("div", "display:flex;gap:4px;align-items:center;margin-top:5px;"); - const swatch = mkEl("div", `width:36px;height:36px;border-radius:4px;border:2px solid #444;background:${initialColor};cursor:pointer;flex-shrink:0;transition:border-color 0.1s;`); + const swatch = mkEl("div", "width:36px;height:36px;border-radius:4px;border:2px solid #444;cursor:pointer;flex-shrink:0;transition:border-color 0.1s;"); swatch.onmouseenter = () => swatch.style.borderColor = "#6688aa"; swatch.onmouseleave = () => swatch.style.borderColor = "#444"; - const colorInput = mkEl("input", "width:0;height:0;opacity:0;position:absolute;", { type: "color", value: initialColor }); + const colorInput = mkEl("input", "width:0;height:0;opacity:0;position:absolute;", { type: "color" }); - const hexInput = mkEl("input", "width:10ch;padding:4px 6px;background:#1e1e1e;color:#ccc;border:1px solid #444;border-radius:4px;font-family:monospace;font-size:12px;flex-shrink:0;box-sizing:border-box;", { value: initialColor.toUpperCase() }); + const hexInput = mkEl("input", "width:10ch;padding:4px 6px;background:#1e1e1e;color:#ccc;border:1px solid #444;border-radius:4px;font-family:monospace;font-size:12px;flex-shrink:0;box-sizing:border-box;"); + + const copyBtn = mkEl("button", "width:24px;height:24px;border-radius:3px;border:1px solid #444;background:#2a2a2a;color:#ccc;cursor:pointer;flex-shrink:0;transition:all 0.1s;display:flex;align-items:center;justify-content:center;padding:0;", { + innerHTML: ``, + title: "Copy HEX" + }); + copyBtn.onmouseenter = () => { copyBtn.style.borderColor = "#6688aa"; copyBtn.style.background = "#3a3a3a"; }; + copyBtn.onmouseleave = () => { copyBtn.style.borderColor = "#444"; copyBtn.style.background = "#2a2a2a"; }; + + copyBtn.onclick = () => { + const hexValue = hexInput.value; + navigator.clipboard.writeText(hexValue).then(() => { + showRaykoToast(`Copied: "${hexValue}"`, "success", node); + }).catch(() => { + showRaykoToast("Failed to copy", "error", node); + }); + }; const eyedropperBtn = mkEl("button", "width:36px;height:36px;border-radius:4px;border:2px solid #444;background:#2a2a2a;color:#ccc;cursor:pointer;font-size:14px;flex-shrink:0;transition:all 0.1s;", { innerHTML: ``, @@ -296,23 +383,12 @@ app.registerExtension({ try { const eyeDropper = new EyeDropper(); const result = await eyeDropper.open(); - const color = normalizeHex(result.sRGBHex); - swatch.style.background = color; - colorInput.value = color; - hexInput.value = color.toUpperCase(); - syncOutputs(color); + syncOutputs(result.sRGBHex); return; } catch (e) { return; } } - const rect = eyedropperBtn.getBoundingClientRect(); - canvasEyedropper.start((hex) => { - const color = normalizeHex(hex); - swatch.style.background = color; - colorInput.value = color; - hexInput.value = color.toUpperCase(); - syncOutputs(color); - }, rect); + canvasEyedropper.start((hex) => syncOutputs(hex), rect); }; swatch.onclick = () => colorInput.click(); @@ -322,37 +398,103 @@ app.registerExtension({ setTimeout(() => swatch.style.borderColor = "#444", 300); }; - topRow.append(swatch, colorInput, hexInput, eyedropperBtn); + topRow.append(swatch, colorInput, hexInput, copyBtn, eyedropperBtn); - const recentLabel = mkEl("div", "color:#999;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px;padding-left:2px;margin-top:5px;", { - textContent: "Recent Colors" - }); + const presetToolbar = mkEl("div", "display:flex;gap:4px;margin-top:5px;"); + + const savePresetBtn = mkEl("button", "flex:1;padding:6px 2px;font-size:11px;border:1px solid #4CAF50;border-radius:5px;background:#1a3a1a;color:#aaffaa;cursor:pointer;", { textContent: "💾 Save" }); + savePresetBtn.onmouseenter = () => { savePresetBtn.style.background = "#2a4a2a"; }; + savePresetBtn.onmouseleave = () => { savePresetBtn.style.background = "#1a3a1a"; }; + + const loadPresetBtn = mkEl("button", "flex:1;padding:6px 2px;font-size:11px;border:1px solid #2196F3;border-radius:5px;background:#1a2a3a;color:#aaddff;cursor:pointer;", { textContent: "📂 Load" }); + loadPresetBtn.onmouseenter = () => { loadPresetBtn.style.background = "#2a3a4a"; }; + loadPresetBtn.onmouseleave = () => { loadPresetBtn.style.background = "#1a2a3a"; }; + + presetToolbar.append(savePresetBtn, loadPresetBtn); + + const recentHeader = mkEl("div", "display:flex;justify-content:space-between;align-items:center;margin-top:5px;"); + const recentLabel = mkEl("div", "color:#999;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;padding-left:2px;", { textContent: "Recent Colors" }); + const clearAllBtn = mkEl("button", "padding:1px 6px;font-size:9px;border:1px solid #f44336;border-radius:4px;background:#353535;color:#ffaaaa;cursor:pointer;transition:background 0.1s;", { textContent: "❌ Clear All" }); + clearAllBtn.onmouseenter = () => { clearAllBtn.style.background = "#4a2a2a"; }; + clearAllBtn.onmouseleave = () => { clearAllBtn.style.background = "#353535"; }; + clearAllBtn.onclick = (e) => { + e.stopPropagation(); + if (node.data.history.length === 0) return; + node.data.history = []; + updateHistoryUI(); + node.updateUI(); + showRaykoToast("History cleared", "success", node); + }; + recentHeader.append(recentLabel, clearAllBtn); const historyRow = mkEl("div", "display:flex;gap:3px;flex-wrap:wrap;"); - const updateHistory = () => { + + const showDeleteColorPopup = (e, color) => { + const existingPopup = document.getElementById("rs-colorpicker-delete-color-popup"); + if (existingPopup) existingPopup.remove(); + + const popup = mkEl("div", "position:fixed;background:#2a2a2a;padding:6px 10px;border:1px solid #f44336;border-radius:4px;z-index:10003;box-shadow:0 2px 10px rgba(0,0,0,0.5);"); + popup.id = "rs-colorpicker-delete-color-popup"; + + const btn = mkEl("button", "padding:4px 10px;font-size:11px;background:#3a1a1a;color:#ffaaaa;border:1px solid #f44336;border-radius:3px;cursor:pointer;transition:background 0.1s;", { textContent: "Delete" }); + btn.onmouseenter = () => { btn.style.background = "#4a2a2a"; }; + btn.onmouseleave = () => { btn.style.background = "#3a1a1a"; }; + btn.onclick = (ev) => { + ev.stopPropagation(); + node.data.history = node.data.history.filter(c => c !== color); + updateHistoryUI(); + node.updateUI(); + popup.remove(); + document.removeEventListener("pointerdown", closePopup, true); + showRaykoToast("Color removed", "success", node); + }; + + popup.appendChild(btn); + popup.style.left = e.clientX + "px"; + popup.style.top = e.clientY + "px"; + + document.body.appendChild(popup); + + const clickTime = Date.now(); + const closePopup = (ev) => { + if (Date.now() - clickTime < 200) return; + if (!popup.contains(ev.target)) { + popup.remove(); + document.removeEventListener("pointerdown", closePopup, true); + } + }; + + setTimeout(() => { + document.addEventListener("pointerdown", closePopup, true); + }, 50); + }; + + let updateHistoryUI = () => { historyRow.innerHTML = ''; - getHistory().forEach(color => { + (node.data.history || []).forEach(color => { const btn = mkEl("div", `width:19px;height:19px;border-radius:3px;background:${color};cursor:pointer;border:1px solid #333;transition:transform 0.1s;`); btn.onmouseenter = () => btn.style.transform = "scale(1.2)"; btn.onmouseleave = () => btn.style.transform = "scale(1)"; - btn.onclick = () => { - swatch.style.background = color; - colorInput.value = color; - hexInput.value = color.toUpperCase(); - syncOutputs(color); + btn.onclick = () => syncOutputs(color); + btn.oncontextmenu = (e) => { + e.preventDefault(); + showDeleteColorPopup(e, color); }; historyRow.append(btn); }); }; - updateHistory(); - root.append(basicLabel, presetsRow, topRow, recentLabel, historyRow); + root.append(basicLabel, presetsRow, topRow, presetToolbar, recentHeader, historyRow); const syncOutputs = (color) => { const normalized = normalizeHex(color); - if (colorWidget) { - colorWidget.value = normalized; - } + node.data.color = normalized; + + if (colorWidget) colorWidget.value = normalized; + + swatch.style.background = normalized; + colorInput.value = normalized; + hexInput.value = normalized.toUpperCase(); const rgb = hexToRGB(normalized); if (node.outputs) { @@ -361,10 +503,288 @@ app.registerExtension({ node.outputs[2].value = `${rgb.r.toFixed(3)}, ${rgb.g.toFixed(3)}, ${rgb.b.toFixed(3)}`; } - addToHistory(normalized); - updateHistory(); + const history = node.data.history || []; + const filtered = history.filter(c => c !== normalized); + filtered.unshift(normalized); + node.data.history = filtered.slice(0, 24); - if (node.graph) node.graph.setDirtyCanvas(true, true); + updateHistoryUI(); + node.updateUI(); + }; + + const showSavePresetDialog = (e) => { + const existingDialog = document.getElementById("rs-colorpicker-save-dialog"); + if (existingDialog) existingDialog.remove(); + + const dialog = mkEl("div", "position:fixed;background:#2a2a2a;padding:10px;border:1px solid #4CAF50;border-radius:6px;z-index:10000;box-shadow:0 4px 15px rgba(0,0,0,0.7);width:220px;"); + dialog.id = "rs-colorpicker-save-dialog"; + + const label = mkEl("div", "color:#999;font-size:11px;margin-bottom:4px;", { textContent: "Preset name:" }); + const input = mkEl("input", "width:100%;padding:5px;background:#111;color:#fff;border:1px solid #444;border-radius:3px;margin-bottom:5px;font-size:12px;box-sizing:border-box;"); + const btns = mkEl("div", "display:flex;gap:5px;"); + const okBtn = mkEl("button", "flex:1;padding:4px;background:#1a3a1a;color:#aaffaa;border:1px solid #4CAF50;border-radius:3px;cursor:pointer;font-size:11px;", { textContent: "OK" }); + const cancelBtn = mkEl("button", "flex:1;padding:4px;background:#2a2a2a;color:#ccc;border:1px solid #444;border-radius:3px;cursor:pointer;font-size:11px;", { textContent: "Cancel" }); + + btns.append(okBtn, cancelBtn); + dialog.append(label, input, btns); + + let finalX = 100, finalY = 100; + if (e && e.clientX !== undefined && e.clientY !== undefined) { + finalX = e.clientX + 10; + finalY = e.clientY + 10; + } + if (finalX + 220 > window.innerWidth) finalX = window.innerWidth - 230; + if (finalY + 120 > window.innerHeight) finalY = window.innerHeight - 130; + if (finalX < 10) finalX = 10; + if (finalY < 10) finalY = 10; + + dialog.style.left = finalX + "px"; + dialog.style.top = finalY + "px"; + + document.body.appendChild(dialog); + setTimeout(() => input.focus(), 50); + + const performSave = async () => { + const name = input.value.trim(); + if (!name) return; + dialog.remove(); + document.removeEventListener("pointerdown", closeOutside, true); + + try { + const response = await api.fetchApi("/rs_colorpicker_save_preset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + color: node.data.color, + history: node.data.history + }) + }); + + if (response.ok) { + showRaykoToast(`Preset "${name}" saved`, "success", node); + } else { + showRaykoToast("Failed to save preset", "error", node); + } + } catch (err) { + showRaykoToast("Failed to save preset", "error", node); + } + }; + + okBtn.onclick = (ev) => { + ev.stopPropagation(); + performSave(); + }; + cancelBtn.onclick = (ev) => { + ev.stopPropagation(); + dialog.remove(); + document.removeEventListener("pointerdown", closeOutside, true); + }; + input.onkeydown = (ev) => { + if (ev.key === "Enter") performSave(); + if (ev.key === "Escape") { + dialog.remove(); + document.removeEventListener("pointerdown", closeOutside, true); + } + }; + + const clickTime = Date.now(); + const closeOutside = (ev) => { + if (Date.now() - clickTime < 300) return; + if (!dialog.contains(ev.target)) { + dialog.remove(); + document.removeEventListener("pointerdown", closeOutside, true); + } + }; + + setTimeout(() => { + document.addEventListener("pointerdown", closeOutside, true); + }, 50); + }; + + const showLoadPresetMenu = async (e) => { + const existingMenu = document.getElementById("rs-colorpicker-load-menu"); + if (existingMenu) existingMenu.remove(); + + const menu = mkEl("div", "position:fixed;background:#1a1a1a;border:2px solid #2196F3;border-radius:6px;max-height:300px;overflow-y:auto;z-index:10001;box-shadow:0 4px 20px rgba(33,150,243,0.3);min-width:250px;"); + menu.id = "rs-colorpicker-load-menu"; + menu.innerHTML = '