From 062dc5bb30b95b13c842a755baa52e0197d69284 Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:45:56 +0300 Subject: [PATCH] Upgrading the RS Styles Loader node --- Rayko_Styles_Loader.py | 194 ++++++++ __init__.py | 2 +- package.json | 2 +- pyproject.toml | 2 +- styles/styles ADD.csv | 6 +- web/rs_styles.js | 1061 +++++++++++++++++++++++++++++++++++++--- 6 files changed, 1184 insertions(+), 83 deletions(-) diff --git a/Rayko_Styles_Loader.py b/Rayko_Styles_Loader.py index 005a2ed..bc1f415 100644 --- a/Rayko_Styles_Loader.py +++ b/Rayko_Styles_Loader.py @@ -24,6 +24,8 @@ logger = logging.getLogger(__name__) class RaykoStylesCSVLoader: STYLES_FOLDER = "styles" + FAVORITES_FILE = "favorites.json" + PRESETS_SUBFOLDER = "presets" @classmethod def get_node_dir(cls) -> str: @@ -33,6 +35,14 @@ class RaykoStylesCSVLoader: def get_styles_folder_path(cls) -> str: return os.path.join(cls.get_node_dir(), cls.STYLES_FOLDER) + @classmethod + def get_favorites_path(cls) -> str: + return os.path.join(cls.get_styles_folder_path(), cls.FAVORITES_FILE) + + @classmethod + def get_presets_folder_path(cls) -> str: + return os.path.join(cls.get_styles_folder_path(), cls.PRESETS_SUBFOLDER) + @classmethod def get_available_style_files(cls) -> List[str]: styles_folder = cls.get_styles_folder_path() @@ -113,6 +123,96 @@ class RaykoStylesCSVLoader: return ("", "") + @classmethod + def load_favorites(cls) -> Dict[str, List[Dict[str, str]]]: + favorites_path = cls.get_favorites_path() + if not os.path.exists(favorites_path): + return {} + + try: + with open(favorites_path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + return {} + except Exception as e: + logger.error(f"Error loading favorites: {str(e)}") + return {} + + @classmethod + def save_favorites(cls, favorites: Dict[str, List[Dict[str, str]]]) -> bool: + favorites_path = cls.get_favorites_path() + styles_folder = cls.get_styles_folder_path() + + try: + os.makedirs(styles_folder, exist_ok=True) + with open(favorites_path, "w", encoding="utf-8") as f: + json.dump(favorites, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + logger.error(f"Error saving favorites: {str(e)}") + return False + + @classmethod + def list_presets(cls) -> List[str]: + presets_folder = cls.get_presets_folder_path() + if not os.path.exists(presets_folder): + return [] + + try: + presets = [ + f[:-5] for f in os.listdir(presets_folder) + if f.endswith('.json') + ] + return sorted(presets, key=lambda x: x.lower()) + except Exception as e: + logger.error(f"Error listing presets: {str(e)}") + return [] + + @classmethod + def save_preset(cls, name: str, preset_data: Dict) -> bool: + presets_folder = cls.get_presets_folder_path() + + try: + os.makedirs(presets_folder, exist_ok=True) + filepath = os.path.join(presets_folder, f"{name}.json") + with open(filepath, "w", encoding="utf-8") as f: + json.dump(preset_data, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + logger.error(f"Error saving preset: {str(e)}") + return False + + @classmethod + def load_preset(cls, name: str) -> Optional[Dict]: + presets_folder = cls.get_presets_folder_path() + filepath = os.path.join(presets_folder, f"{name}.json") + + if not os.path.exists(filepath): + return None + + try: + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + logger.error(f"Error loading preset: {str(e)}") + return None + + @classmethod + def delete_preset(cls, name: str) -> bool: + presets_folder = cls.get_presets_folder_path() + filepath = os.path.join(presets_folder, f"{name}.json") + + if not os.path.exists(filepath): + return False + + try: + os.remove(filepath) + return True + except Exception as e: + logger.error(f"Error deleting preset: {str(e)}") + return False + @classmethod def INPUT_TYPES(cls) -> Dict[str, Any]: return { @@ -267,6 +367,100 @@ class RaykoServer: except Exception as e: logger.error(f"Error loading styles: {e}") return web.json_response({'error': str(e)}, status=500) + + @server.PromptServer.instance.routes.get("/rayko_get_favorites") + async def get_favorites(request): + try: + favorites = RaykoStylesCSVLoader.load_favorites() + return web.json_response(favorites) + except Exception as e: + logger.error(f"Error getting favorites: {e}") + return web.json_response({"error": str(e)}, status=500) + + @server.PromptServer.instance.routes.post("/rayko_save_favorites") + async def save_favorites(request): + try: + data = await request.json() + if not isinstance(data, dict): + return web.json_response({'error': 'Invalid data format'}, status=400) + + success = RaykoStylesCSVLoader.save_favorites(data) + if success: + return web.json_response({'success': True}) + else: + return web.json_response({'error': 'Failed to save favorites'}, status=500) + except Exception as e: + logger.error(f"Error saving favorites: {e}") + return web.json_response({'error': str(e)}, status=500) + + @server.PromptServer.instance.routes.get("/rayko_get_presets") + async def get_presets(request): + try: + presets = RaykoStylesCSVLoader.list_presets() + return web.json_response(presets) + except Exception as e: + logger.error(f"Error getting presets: {e}") + return web.json_response({"error": str(e)}, status=500) + + @server.PromptServer.instance.routes.post("/rayko_save_preset") + async def 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) + + preset_data = { + "csv_file": data.get("csv_file", ""), + "styles": data.get("styles", []) + } + + success = RaykoStylesCSVLoader.save_preset(name, preset_data) + if success: + return web.json_response({'success': True}) + else: + return web.json_response({'error': 'Failed to save preset'}, status=500) + except Exception as e: + logger.error(f"Error saving preset: {e}") + return web.json_response({'error': str(e)}, status=500) + + @server.PromptServer.instance.routes.post("/rayko_load_preset") + async def load_preset(request): + try: + data = await request.json() + name = data.get("name") + if not name: + return web.json_response({'error': 'Name required'}, status=400) + + preset = RaykoStylesCSVLoader.load_preset(name) + if preset: + return web.json_response(preset) + else: + return web.json_response({'error': 'Preset not found'}, status=404) + except Exception as e: + logger.error(f"Error loading preset: {e}") + return web.json_response({'error': str(e)}, status=500) + + @server.PromptServer.instance.routes.post("/rayko_delete_preset") + async def delete_preset(request): + try: + data = await request.json() + name = data.get("name") + if not name: + return web.json_response({'error': 'Name required'}, status=400) + + success = RaykoStylesCSVLoader.delete_preset(name) + if success: + return web.json_response({'success': True}) + else: + return web.json_response({'error': 'Preset not found'}, status=404) + except Exception as e: + logger.error(f"Error deleting preset: {e}") + return web.json_response({'error': str(e)}, status=500) except Exception as e: logger.error(f"Failed to register server routes: {e}") diff --git a/__init__.py b/__init__.py index bb761d5..2cbb5ab 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.29.1" +__version__ = "0.30.0" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index af20327..58e4cc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.29.1", + "version": "0.30.0", "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 ae8a070..b35816d 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.29.1" +version = "0.30.0" 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/styles/styles ADD.csv b/styles/styles ADD.csv index 449e1d8..444ea7b 100644 --- a/styles/styles ADD.csv +++ b/styles/styles ADD.csv @@ -18,11 +18,11 @@ No Style,, "Style | 3D model","professional 3d model {prompt} . octane render, highly detailed, volumetric, dramatic lighting","ugly, deformed, noisy, low poly, blurry, painting","" "Style | Pixel art","pixel-art {prompt} . low-res, blocky, pixel art style, 8-bit graphics","sloppy, messy, blurry, noisy, highly detailed, ultra textured, photo, realistic","" "Style | Line close-up texture","texture {prompt} top down close-up","ugly, deformed, noisy, blurry","" -"Style | Fooocus enhanced,{prompt}","(worst quality, low quality, normal quality, lowres, low details, oversaturated, undersaturated, overexposed, underexposed, grayscale, bw, bad photo, bad photography, bad art:1.4), (watermark, signature, text font, username, error, logo, words, letters, digits, autograph, trademark, name:1.2), (blur, blurry, grainy), morbid, ugly, asymmetrical, mutated malformed, mutilated, poorly lit, bad shadow, draft, cropped, out of frame, cut off, censored, jpeg artifacts, out of focus, glitch, duplicate, (airbrushed, cartoon, anime, semi-realistic, cgi, render, blender, digital art, manga, amateur:1.3), (3D ,3D Game, 3D Game Scene, 3D Character:1.1), (bad hands, bad anatomy, bad body, bad face, bad teeth, bad arms, bad legs, deformities:1.3)","" +"Style | Fooocus enhanced","{prompt},(worst quality, low quality, normal quality, lowres, low details, oversaturated, undersaturated, overexposed, underexposed, grayscale, bw, bad photo, bad photography, bad art:1.4), (watermark, signature, text font, username, error, logo, words, letters, digits, autograph, trademark, name:1.2), (blur, blurry, grainy), morbid, ugly, asymmetrical, mutated malformed, mutilated, poorly lit, bad shadow, draft, cropped, out of frame, cut off, censored, jpeg artifacts, out of focus, glitch, duplicate, (airbrushed, cartoon, anime, semi-realistic, cgi, render, blender, digital art, manga, amateur:1.3), (3D ,3D Game, 3D Game Scene, 3D Character:1.1), (bad hands, bad anatomy, bad body, bad face, bad teeth, bad arms, bad legs, deformities:1.3)","" "Style | Fooocus clear","cinematic still {prompt} . emotional, harmonious, vignette, 4k epic detailed, shot on kodak, 35mm photo, sharp focus, high budget, cinemascope, moody, epic, gorgeous, film grain, grainy","anime, cartoon, graphic, (blur, blurry, bokeh), text, painting, crayon, graphite, abstract, glitch, deformed, mutated, ugly, disfigured","" -"Style | Fooocus masterpiece,(masterpiece), (best quality), (ultra-detailed), {prompt}, illustration, disheveled hair, detailed eyes, perfect composition, moist skin, intricate details, earrings, by wlop","longbody, lowres, bad anatomy, bad hands, missing fingers, pubic hair,extra digit, fewer digits, cropped, worst quality, low quality","" +"Style | Fooocus masterpiece","(masterpiece), (best quality), (ultra-detailed), {prompt}, illustration, disheveled hair, detailed eyes, perfect composition, moist skin, intricate details, earrings, by wlop","longbody, lowres, bad anatomy, bad hands, missing fingers, pubic hair,extra digit, fewer digits, cropped, worst quality, low quality","" "Style | Fooocus photo","photograph {prompt}, 50mm . cinematic 4k epic detailed 4k epic detailed photograph shot on kodak detailed cinematic hbo dark moody, 35mm photo, grainy, vignette, vintage, Kodachrome, Lomography, stained, highly detailed, found footage","Brad Pitt, bokeh, depth of field, blurry, cropped, regular face, saturated, contrast, deformed iris, deformed pupils, semi-realistic, cgi, 3d, render, sketch, cartoon, drawing, anime, text, cropped, out of frame, worst quality, low quality, jpeg artifacts, ugly, duplicate, morbid, mutilated, extra fingers, mutated hands, poorly drawn hands, poorly drawn face, mutation, deformed, dehydrated, bad anatomy, bad proportions, extra limbs, cloned face, disfigured, gross proportions, malformed limbs, missing arms, missing legs, extra arms, extra legs, fused fingers, too many fingers, long neck","" -"Style | Fooocus negative prompt,{prompt}","deformed, bad anatomy, disfigured, poorly drawn face, mutated, extra limb, ugly, poorly drawn hands, missing limb, floating limbs, disconnected limbs, disconnected head, malformed hands, long neck, mutated hands and fingers, bad hands, missing fingers, cropped, worst quality, low quality, mutation, poorly drawn, huge calf, bad hands, fused hand, missing hand, disappearing arms, disappearing thigh, disappearing calf, disappearing legs, missing fingers, fused fingers, abnormal eye proportion, Abnormal hands, abnormal legs, abnormal feet, abnormal fingers, drawing, painting, crayon, sketch, graphite, impressionist, noisy, blurry, soft, deformed, ugly, anime, cartoon, graphic, text, painting, crayon, graphite, abstract, glitch","" +"Style | Fooocus negative prompt","{prompt},deformed, bad anatomy, disfigured, poorly drawn face, mutated, extra limb, ugly, poorly drawn hands, missing limb, floating limbs, disconnected limbs, disconnected head, malformed hands, long neck, mutated hands and fingers, bad hands, missing fingers, cropped, worst quality, low quality, mutation, poorly drawn, huge calf, bad hands, fused hand, missing hand, disappearing arms, disappearing thigh, disappearing calf, disappearing legs, missing fingers, fused fingers, abnormal eye proportion, Abnormal hands, abnormal legs, abnormal feet, abnormal fingers, drawing, painting, crayon, sketch, graphite, impressionist, noisy, blurry, soft, deformed, ugly, anime, cartoon, graphic, text, painting, crayon, graphite, abstract, glitch","" "Style | Fooocus movie","cinematic still {prompt} . emotional, harmonious, vignette, highly detailed, high budget, bokeh, cinemascope, moody, epic, gorgeous, film grain, grainy","anime, cartoon, graphic, text, painting, crayon, graphite, abstract, glitch, deformed, mutated, ugly, disfigured","" ||| SDXL COMFYUI PORT ,, "Style | Advertising","Advertising poster style {prompt} . Professional, modern, product-focused, commercial, eye-catching, highly detailed","noisy, blurry, amateurish, sloppy, unattractive","" diff --git a/web/rs_styles.js b/web/rs_styles.js index 1a966af..d809e9c 100644 --- a/web/rs_styles.js +++ b/web/rs_styles.js @@ -15,16 +15,27 @@ app.registerExtension({ this.data = { active_csv_file: "", styles: [], - csv_list: [] + csv_list: [], + favorites: {} }; this.rowHeight = 28; + this.toolbarHeight = 28; this.padding = 10; this.labelWidth = 110; this.targetWidth = 340; + this.targetHeight = 300; + this.MIN_WIDTH = 340; + this.MIN_HEIGHT = 340; + this.clickZones = []; this.menuPosition = null; this.menuScrollPosition = 0; + this.expandedFolders = {}; + + this.manual_size = false; + this.scrollOffset = 0; + this.isAutoResizing = false; const self = this; @@ -45,8 +56,12 @@ app.registerExtension({ this.data = { ...this.data, ...savedData, - csv_list: this.data.csv_list.length > 0 ? this.data.csv_list : savedData.csv_list || [] + csv_list: this.data.csv_list.length > 0 ? this.data.csv_list : savedData.csv_list || [], + favorites: savedData.favorites || {} }; + this.expandedFolders = savedData.expandedFolders || {}; + this.manual_size = savedData.manual_size || false; + this.scrollOffset = savedData.scrollOffset || 0; } } catch (e) { console.error("[Rayko] Error loading saved data", e); @@ -66,7 +81,99 @@ app.registerExtension({ }); } - this.setSize([this.targetWidth, 300]); + this.setSize([this.targetWidth, this.targetHeight]); + + this.syncData = function() { + if (this.hiddenWidget) { + this.hiddenWidget.value = JSON.stringify({ + ...this.data, + expandedFolders: this.expandedFolders, + manual_size: this.manual_size, + scrollOffset: this.scrollOffset + }); + } + if (this.graph) { + this.graph.changeTracker?.dispatchEvent(new Event("change")); + } + }; + + this.updateUI = function() { + this.syncData(); + if (this.graph) { + this.graph.setDirtyCanvas(true, true); + this.graph.changeTracker?.dispatchEvent(new Event("change")); + } + }; + + this.wheelHandler = function(e) { + if (app.canvas.node_over !== self) { + return; + } + + const graphPos = app.canvas.graph_mouse; + if (!graphPos) return; + + const relY = graphPos[1] - self.pos[1]; + + const startY = 80; + const rowH = self.rowHeight; + let y = startY; + y += rowH + 10; + y += rowH + 10; + y += rowH + 10; + y += rowH + 15; + y += rowH + 5; + y += self.toolbarHeight + 8; + + const availableHeight = self.size[1] - y - 10; + const maxVisibleStyles = Math.max(1, Math.floor(availableHeight / rowH)); + const stylesEndY = y + maxVisibleStyles * rowH; + + if (relY < y || relY > stylesEndY) { + return; + } + + if (self.data.styles.length <= maxVisibleStyles) return; + + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + const delta = e.deltaY > 0 ? 1 : -1; + const maxOffset = self.data.styles.length - maxVisibleStyles; + const newOffset = Math.max(0, Math.min(self.scrollOffset + delta, maxOffset)); + + if (newOffset !== self.scrollOffset) { + self.scrollOffset = newOffset; + self.syncData(); + self.graph.setDirtyCanvas(true, true); + } + }; + + const initialCanvas = app.canvas.canvas; + initialCanvas.addEventListener('wheel', this.wheelHandler, { capture: true, passive: false }); + this.oldWheelCanvas = initialCanvas; + this.oldWheelHandler = this.wheelHandler; + + this.visibilityHandler = function() { + if (!document.hidden) { + setTimeout(() => { + const currentCanvas = app.canvas.canvas; + + if (self.oldWheelCanvas && self.oldWheelCanvas !== currentCanvas) { + self.oldWheelCanvas.removeEventListener('wheel', self.oldWheelHandler, { capture: true, passive: false }); + currentCanvas.addEventListener('wheel', self.wheelHandler, { capture: true, passive: false }); + self.oldWheelCanvas = currentCanvas; + } + + if (self.graph) { + self.graph.setDirtyCanvas(true, true); + } + self.updateUI(); + }, 150); + } + }; + document.addEventListener("visibilitychange", this.visibilityHandler); this.loadCSVList = async function() { try { @@ -86,6 +193,144 @@ app.registerExtension({ } }; + this.loadFavoritesFromServer = async function() { + try { + const response = await api.fetchApi("/rayko_get_favorites"); + if (response.ok) { + const favorites = await response.json(); + self.data.favorites = favorites; + return favorites; + } + } catch (e) { + console.error("[Rayko] Error loading favorites:", e); + } + return {}; + }; + + this.saveFavoritesToServer = async function() { + try { + const response = await api.fetchApi("/rayko_save_favorites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(self.data.favorites) + }); + if (!response.ok) { + console.error("[Rayko] Error saving favorites:", await response.text()); + } + } catch (e) { + console.error("[Rayko] Error saving favorites:", e); + } + }; + + this.migrateFavorites = async function() { + const serverFavorites = await self.loadFavoritesFromServer(); + + const hasServerData = Object.keys(serverFavorites).length > 0; + const hasLocalData = self.data.favorites && Object.keys(self.data.favorites).length > 0; + + if (!hasServerData && hasLocalData) { + console.log("[Rayko] Migrating favorites from node_data to server"); + await self.saveFavoritesToServer(); + } else if (hasServerData) { + self.data.favorites = serverFavorites; + } + }; + + this.loadPresetsList = async function() { + try { + const response = await api.fetchApi("/rayko_get_presets"); + if (response.ok) { + return await response.json(); + } + } catch (e) { + console.error("[Rayko] Error loading presets:", e); + } + return []; + }; + + this.savePreset = async function(name) { + const presetData = { + csv_file: self.data.active_csv_file, + styles: self.data.styles + }; + + try { + const response = await api.fetchApi("/rayko_save_preset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, ...presetData }) + }); + return response.ok; + } catch (e) { + console.error("[Rayko] Error saving preset:", e); + return false; + } + }; + + this.loadPreset = async function(name) { + try { + const response = await api.fetchApi("/rayko_load_preset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }) + }); + if (response.ok) { + const data = await response.json(); + + requestAnimationFrame(() => { + if (data.csv_file) { + self.data.active_csv_file = data.csv_file; + } + if (data.styles) { + self.data.styles = data.styles; + } + self.scrollOffset = 0; + self.manual_size = false; + + const startY = 80; + const rowH = self.rowHeight; + let y = startY; + y += rowH + 10; + y += rowH + 10; + y += rowH + 10; + y += rowH + 15; + y += rowH + 5; + y += self.toolbarHeight + 8; + + const desiredVisible = Math.min(self.data.styles.length, 10); + const calculatedHeight = Math.max(self.MIN_HEIGHT, y + desiredVisible * rowH + 10); + + self.setSize([self.targetWidth, calculatedHeight]); + + if (self.graph) { + self.graph.setDirtyCanvas(true, true); + } + + self.syncData(); + }); + + return true; + } + } catch (e) { + console.error("[Rayko] Error loading preset:", e); + } + return false; + }; + + this.deletePreset = async function(name) { + try { + const response = await api.fetchApi("/rayko_delete_preset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }) + }); + return response.ok; + } catch (e) { + console.error("[Rayko] Error deleting preset:", e); + return false; + } + }; + this.drawSeparator = function(ctx, text, x, y, w, h) { ctx.fillStyle = "#444"; ctx.fillRect(x, y + 8, w, 1); @@ -146,6 +391,53 @@ app.registerExtension({ ctx.fillText(label, x + w/2 + 8, y + h/2); }; + this.drawRoundedRect = 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(); + }; + + this.drawToolbarButton = function(ctx, text, icon, x, y, w, h, borderColor, isActive = true) { + const radius = 5; + ctx.fillStyle = "#242427"; + this.drawRoundedRect(ctx, x, y, w, h, radius); + ctx.fill(); + + ctx.strokeStyle = isActive ? borderColor : "#444"; + ctx.lineWidth = 1; + this.drawRoundedRect(ctx, x, y, w, h, radius); + ctx.stroke(); + + ctx.font = "bold 10px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const fullText = icon + " " + text; + ctx.fillStyle = isActive ? "#fff" : "#666"; + ctx.fillText(fullText, x + w/2, y + h/2); + }; + + this.getStylesListStartY = function() { + const startY = 80; + const rowH = self.rowHeight; + let y = startY; + y += rowH + 10; + y += rowH + 10; + y += rowH + 10; + y += rowH + 15; + y += rowH + 5; + y += self.toolbarHeight + 8; + return y; + }; + this.onDrawForeground = function(ctx, visibleRect) { this.clickZones = []; const startY = 80; @@ -162,20 +454,87 @@ app.registerExtension({ this.clickZones.push({ type: "upload", x: pad, y: y, w: self.size[0] - pad*2, h: rowH }); y += rowH + 10; - self.drawButton(ctx, "➕ ADD STYLE", pad, y, self.size[0] - pad*2, rowH, "#9C27B0"); + self.drawButton(ctx, "⭐ FAVORITES", pad, y, self.size[0] - pad*2, rowH, "#FFD700"); + this.clickZones.push({ type: "favorites", x: pad, y: y, w: self.size[0] - pad*2, h: rowH }); + y += rowH + 10; + + self.drawButton(ctx, " ADD STYLE", pad, y, self.size[0] - pad*2, rowH, "#9C27B0"); this.clickZones.push({ type: "add_style", x: pad, y: y, w: self.size[0] - pad*2, h: rowH }); y += rowH + 15; self.drawSeparator(ctx, "ACTIVE STYLES", pad, y, self.size[0] - pad*2, rowH); - y += rowH + 10; + y += rowH + 5; + + const toolbarY = y; + const toolbarH = self.toolbarHeight; + const availableWidth = self.size[0] - pad * 2; + + const baseClearW = 70; + const baseResetW = 85; + const baseSaveW = 75; + const baseLoadW = 75; + const gap = 6; + + const baseAvailableWidth = self.MIN_WIDTH - pad * 2; + const scale = Math.min(1.5, Math.max(1, availableWidth / baseAvailableWidth)); + + const clearW = Math.floor(baseClearW * scale); + const resetW = Math.floor(baseResetW * scale); + const saveW = Math.floor(baseSaveW * scale); + const loadW = Math.floor(baseLoadW * scale); + + const totalW = clearW + resetW + saveW + loadW + gap * 3; + const startX = pad + (availableWidth - totalW) / 2; + + self.drawToolbarButton(ctx, "Clear", "", startX, toolbarY, clearW, toolbarH, "#f44336", self.data.styles.length > 0); + this.clickZones.push({ type: "clear_all", x: startX, y: toolbarY, w: clearW, h: toolbarH }); + + const resetX = startX + clearW + gap; + self.drawToolbarButton(ctx, "Reset", "🔄", resetX, toolbarY, resetW, toolbarH, "#2196F3", self.manual_size); + this.clickZones.push({ type: "reset_size", x: resetX, y: toolbarY, w: resetW, h: toolbarH }); + + const saveX = resetX + resetW + gap; + self.drawToolbarButton(ctx, "Save", "💾", saveX, toolbarY, saveW, toolbarH, "#4CAF50", true); + this.clickZones.push({ type: "save_preset", x: saveX, y: toolbarY, w: saveW, h: toolbarH }); + + const loadX = saveX + saveW + gap; + self.drawToolbarButton(ctx, "Load", "📂", loadX, toolbarY, loadW, toolbarH, "#2196F3", true); + this.clickZones.push({ type: "load_preset", x: loadX, y: toolbarY, w: loadW, h: toolbarH }); + + y += toolbarH + 8; + + let maxVisibleStyles = Math.max(1, Math.floor((self.size[1] - y - 10) / rowH)); + + if (!self.manual_size) { + const desiredVisible = Math.min(self.data.styles.length, 10); + const calculatedHeight = Math.max(self.MIN_HEIGHT, y + desiredVisible * rowH + 10); + + if (Math.abs(self.size[1] - calculatedHeight) > 1) { + self.isAutoResizing = true; + self.setSize([self.targetWidth, calculatedHeight]); + self.isAutoResizing = false; + } + + const currentAvailableHeight = calculatedHeight - y - 10; + maxVisibleStyles = Math.max(1, Math.floor(currentAvailableHeight / rowH)); + } + + const maxOffset = Math.max(0, self.data.styles.length - maxVisibleStyles); + if (self.scrollOffset > maxOffset) { + self.scrollOffset = maxOffset; + } + + const visibleStart = self.scrollOffset; + const visibleEnd = Math.min(visibleStart + maxVisibleStyles, self.data.styles.length); const rightPanelWidth = 40; const toggleStartX = 20; const toggleRadius = 7; const nameStartX = 42; - for (let i = 0; i < self.data.styles.length; i++) { - const row = self.data.styles[i]; + for (let i = 0; i < visibleEnd - visibleStart; i++) { + const dataIdx = visibleStart + i; + const row = self.data.styles[dataIdx]; const styleY = y + (i * rowH); const h = rowH - 2; const centerY = styleY + h/2; @@ -187,7 +546,7 @@ app.registerExtension({ ctx.beginPath(); ctx.arc(toggleStartX, centerY, toggleRadius, 0, Math.PI * 2); ctx.fill(); - this.clickZones.push({ type: "toggle", index: i, x: toggleStartX - toggleRadius - 5, y: styleY, w: toggleRadius * 2 + 10, h: h }); + this.clickZones.push({ type: "toggle", index: dataIdx, x: toggleStartX - toggleRadius - 5, y: styleY, w: toggleRadius * 2 + 10, h: h }); const nameW = self.size[0] - nameStartX - rightPanelWidth - pad - 10; @@ -210,17 +569,36 @@ app.registerExtension({ } ctx.fillText(displayName, nameStartX, centerY); - this.clickZones.push({ type: "name", index: i, x: nameStartX, y: styleY, w: nameW, h: h }); + this.clickZones.push({ type: "name", index: dataIdx, x: nameStartX, y: styleY, w: nameW, h: h }); const delX = self.size[0] - rightPanelWidth; ctx.fillStyle = "#f44336"; ctx.fillText("❌", delX, centerY); - this.clickZones.push({ type: "delete", index: i, x: delX, y: styleY, w: 30, h: h }); + this.clickZones.push({ type: "delete", index: dataIdx, x: delX, y: styleY, w: 30, h: h }); } - const totalH = y + (self.data.styles.length * rowH) + 20; - if (self.size[1] < totalH) { - self.setSize([self.targetWidth, totalH]); + if (self.data.styles.length > maxVisibleStyles) { + if (self.scrollOffset > 0) { + const indicatorY = y - 2; + ctx.fillStyle = "rgba(255, 215, 0, 0.6)"; + ctx.beginPath(); + ctx.moveTo(self.size[0]/2 - 8, indicatorY); + ctx.lineTo(self.size[0]/2 + 8, indicatorY); + ctx.lineTo(self.size[0]/2, indicatorY - 8); + ctx.closePath(); + ctx.fill(); + } + + if (visibleEnd < self.data.styles.length) { + const indicatorY = y + (visibleEnd - visibleStart) * rowH + 2; + ctx.fillStyle = "rgba(255, 215, 0, 0.6)"; + ctx.beginPath(); + ctx.moveTo(self.size[0]/2 - 8, indicatorY); + ctx.lineTo(self.size[0]/2 + 8, indicatorY); + ctx.lineTo(self.size[0]/2, indicatorY + 8); + ctx.closePath(); + ctx.fill(); + } } }; @@ -240,6 +618,10 @@ app.registerExtension({ self.uploadCSVFile(); return true; } + if (zone.type === "favorites") { + self.showFavoritesMenu(e); + return true; + } if (zone.type === "add_style") { self.showStyleSelector(e); return true; @@ -253,15 +635,82 @@ app.registerExtension({ } if (zone.type === "delete") { self.data.styles.splice(zone.index, 1); + self.scrollOffset = 0; + self.manual_size = false; + self.syncData(); + + requestAnimationFrame(() => { + const startY = 80; + const rowH = self.rowHeight; + let y = startY; + y += rowH + 10; + y += rowH + 10; + y += rowH + 10; + y += rowH + 15; + y += rowH + 5; + y += self.toolbarHeight + 8; + + const desiredVisible = Math.min(self.data.styles.length, 10); + const calculatedHeight = Math.max(self.MIN_HEIGHT, y + desiredVisible * rowH + 10); + + self.setSize([self.targetWidth, calculatedHeight]); + if (self.graph) { + self.graph.setDirtyCanvas(true, true); + } + }); + + return true; + } + if (zone.type === "clear_all") { + if (self.data.styles.length === 0) return true; + self.data.styles = []; + self.scrollOffset = 0; + self.manual_size = false; + self.syncData(); + + requestAnimationFrame(() => { + self.setSize([self.targetWidth, self.MIN_HEIGHT]); + if (self.graph) { + self.graph.setDirtyCanvas(true, true); + } + }); + + return true; + } + if (zone.type === "reset_size") { + self.manual_size = false; + self.scrollOffset = 0; self.syncData(); self.updateUI(); return true; } + if (zone.type === "save_preset") { + self.showSavePresetDialog(e); + return true; + } + if (zone.type === "load_preset") { + self.showLoadPresetMenu(e); + return true; + } } } return false; }; + const onResize = this.onResize; + this.onResize = function(size) { + if (size[0] < self.MIN_WIDTH) size[0] = self.MIN_WIDTH; + if (size[1] < self.MIN_HEIGHT) size[1] = self.MIN_HEIGHT; + + if (!self.isAutoResizing) { + self.manual_size = true; + self.syncData(); + } + if (onResize) { + return onResize.apply(this, arguments); + } + }; + this.showCSVSelector = function(clickEvent) { const list = self.data.csv_list || []; if (!list.length) return; @@ -289,7 +738,7 @@ app.registerExtension({ menu.appendChild(item); }); - if (clickEvent) { + if (clickEvent && clickEvent.clientX !== undefined) { menu.style.left = (clickEvent.clientX + 8) + "px"; menu.style.top = clickEvent.clientY + "px"; } @@ -348,6 +797,148 @@ app.registerExtension({ fileInput.click(); }; + this.toggleFavorite = async function(styleFullName, styleDisplayName) { + const file = self.data.active_csv_file; + if (!self.data.favorites[file]) { + self.data.favorites[file] = []; + } + const favs = self.data.favorites[file]; + const idx = favs.findIndex(f => f.fullName === styleFullName); + + if (idx > -1) { + favs.splice(idx, 1); + } else { + favs.push({ fullName: styleFullName, displayName: styleDisplayName }); + } + + await self.saveFavoritesToServer(); + }; + + this.showFavoritesMenu = async function(clickEvent) { + await self.loadFavoritesFromServer(); + + const file = self.data.active_csv_file; + const favs = self.data.favorites[file] || []; + + const existingMenu = document.getElementById("rayko-favorites-menu"); + if (existingMenu) existingMenu.remove(); + + const menu = document.createElement("div"); + menu.id = "rayko-favorites-menu"; + menu.style.cssText = `position:fixed;background:#1a1a1a;border:2px solid #FFD700;border-radius:6px;max-height:400px;overflow:hidden;z-index:10001;box-shadow:0 4px 20px rgba(255,215,0,0.3);min-width:250px;display:flex;flex-direction:column;`; + + const header = document.createElement("div"); + header.textContent = `⭐ Favorites: ${file}`; + header.style.cssText = `padding:10px 12px;color:#fff;font-weight:bold;border-bottom:1px solid #333;background:#252525;flex-shrink:0;`; + menu.appendChild(header); + + const contentContainer = document.createElement("div"); + contentContainer.style.cssText = `overflow-y:auto;flex-grow:1;`; + menu.appendChild(contentContainer); + + if (clickEvent && clickEvent.clientX !== undefined) { + menu.style.left = (clickEvent.clientX + 8) + "px"; + menu.style.top = clickEvent.clientY + "px"; + } + + function isStyleAlreadyAdded(styleFullName) { + return self.data.styles.some(row => + row.name === styleFullName && row.file === self.data.active_csv_file + ); + } + + function renderFavoritesContent() { + contentContainer.innerHTML = ""; + + const currentFavs = self.data.favorites[file] || []; + + if (currentFavs.length === 0) { + const emptyMsg = document.createElement("div"); + emptyMsg.textContent = "No favorites for this file yet."; + emptyMsg.style.cssText = `padding:15px;color:#888;font-size:12px;text-align:center;`; + contentContainer.appendChild(emptyMsg); + return; + } + + for (const fav of currentFavs) { + const isAdded = isStyleAlreadyAdded(fav.fullName); + + const item = document.createElement("div"); + item.style.cssText = `display:flex;justify-content:space-between;align-items:center;padding:10px 12px;cursor:pointer;border-bottom:1px solid #333;font-size:13px;background:#1a1a1a;transition:background-color 0.15s;`; + item.onmouseover = () => item.style.backgroundColor = "#333"; + item.onmouseout = () => item.style.backgroundColor = "#1a1a1a"; + + const textSpan = document.createElement("span"); + textSpan.textContent = (isAdded ? "✓ " : "🎨 ") + fav.displayName; + textSpan.style.cssText = `flex-grow:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${isAdded ? '#4CAF50' : '#ddd'};`; + + const starSpan = document.createElement("span"); + starSpan.textContent = "★"; + starSpan.style.cssText = `color:#FFD700;font-size:16px;margin-left:10px;flex-shrink:0;cursor:pointer;`; + + starSpan.onclick = async (ev) => { + ev.stopPropagation(); + await self.toggleFavorite(fav.fullName, fav.displayName); + renderFavoritesContent(); + }; + + item.onclick = (ev) => { + ev.stopPropagation(); + self.addStyleRow(fav.fullName, fav.displayName); + renderFavoritesContent(); + }; + + item.appendChild(textSpan); + item.appendChild(starSpan); + contentContainer.appendChild(item); + } + } + + renderFavoritesContent(); + + const closeMenu = () => { + menu.remove(); + document.removeEventListener("keydown", closeEsc, true); + if (closeTimer) { + clearTimeout(closeTimer); + closeTimer = null; + } + }; + + const closeEsc = (ev) => { + if (ev.key === "Escape") closeMenu(); + }; + + let closeTimer = null; + const closeDelay = 300; + + menu.addEventListener("mouseleave", () => { + closeTimer = setTimeout(() => { + closeMenu(); + }, closeDelay); + }); + + menu.addEventListener("mouseenter", () => { + if (closeTimer) { + clearTimeout(closeTimer); + closeTimer = null; + } + }); + + setTimeout(() => { + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = (window.innerWidth - rect.width - 10) + "px"; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = (window.innerHeight - rect.height - 10) + "px"; + } + }, 10); + + document.body.appendChild(menu); + document.addEventListener("keydown", closeEsc, true); + }; + this.showStyleSelector = async function(clickEvent) { if (!self.data.active_csv_file || self.data.active_csv_file === "No CSV files") { alert("⚠️ Upload CSV first!"); @@ -399,8 +990,9 @@ app.registerExtension({ top: clickEvent.clientY + "px" }; self.menuScrollPosition = 0; + self.expandedFolders = self.expandedFolders || {}; - self.showStyleTreeMenu(tree, {}, self.menuPosition); + self.showStyleTreeMenu(tree, self.expandedFolders, self.menuPosition); } } catch (e) { alert("Error: " + e.message); @@ -415,53 +1007,166 @@ app.registerExtension({ const menu = document.createElement("div"); menu.id = "rayko-style-menu"; - menu.style.cssText = `position:fixed;background:#1a1a1a;border:1px solid #444;border-radius:6px;max-height:400px;overflow-y:auto;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,0.5);min-width:250px;`; + menu.style.cssText = `position:fixed;background:#1a1a1a;border:2px solid #9C27B0;border-radius:6px;max-height:400px;overflow:hidden;z-index:10001;box-shadow:0 4px 20px rgba(156,39,176,0.3);min-width:250px;display:flex;flex-direction:column;`; const header = document.createElement("div"); header.textContent = `📁 ${self.data.active_csv_file}`; - header.style.cssText = `padding:10px 12px;color:#fff;font-weight:bold;border-bottom:1px solid #333;background:#252525;position:sticky;top:0;`; + header.style.cssText = `padding:10px 12px;color:#fff;font-weight:bold;border-bottom:1px solid #333;background:#252525;flex-shrink:0;`; menu.appendChild(header); + + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.placeholder = " Search styles..."; + searchInput.style.cssText = `width:calc(100% - 24px);margin:8px 12px;padding:8px;background:#2a2a2a;border:1px solid #444;border-radius:4px;color:#fff;font-size:12px;outline:none;flex-shrink:0;`; + searchInput.autofocus = true; + menu.appendChild(searchInput); const contentContainer = document.createElement("div"); contentContainer.id = "rayko-style-content"; + contentContainer.style.cssText = `overflow-y:auto;flex-grow:1;`; menu.appendChild(contentContainer); + let currentSearchQuery = ""; + const closeMenu = () => { self.menuPosition = null; self.menuScrollPosition = 0; menu.remove(); - document.removeEventListener("pointerdown", closeOutside, true); document.removeEventListener("keydown", closeEsc, true); + if (closeTimer) { + clearTimeout(closeTimer); + closeTimer = null; + } }; - const closeOutside = (ev) => { - if (!menu.contains(ev.target)) closeMenu(); - }; + const closeEsc = (ev) => { if (ev.key === "Escape") closeMenu(); }; + + let closeTimer = null; + const closeDelay = 300; - function renderContent() { - contentContainer.innerHTML = ""; + menu.addEventListener("mouseleave", () => { + closeTimer = setTimeout(() => { + closeMenu(); + }, closeDelay); + }); + + menu.addEventListener("mouseenter", () => { + if (closeTimer) { + clearTimeout(closeTimer); + closeTimer = null; + } + }); + + searchInput.addEventListener("input", (e) => { + currentSearchQuery = e.target.value.toLowerCase(); + renderContent(); + }); + + function isStyleAlreadyAdded(styleFullName) { + return self.data.styles.some(row => + row.name === styleFullName && row.file === self.data.active_csv_file + ); + } + + function renderStyleItem(container, styleFullName, styleDisplayName, displayText = null, indentLevel = 0) { + const isAdded = isStyleAlreadyAdded(styleFullName); - if (tree._styles && tree._styles.length > 0) { - for (const styleObj of tree._styles) { - const item = document.createElement("div"); - item.textContent = "🎨 " + styleObj.displayName; - item.style.cssText = `padding:10px 12px;cursor:pointer;color:#ddd;border-bottom:1px solid #333;font-size:13px;`; - item.style.backgroundColor = "#1a1a1a"; - item.onmouseover = () => item.style.backgroundColor = "#333"; - item.onmouseout = () => item.style.backgroundColor = "#1a1a1a"; - item.onclick = (ev) => { ev.stopPropagation(); self.addStyleRow(styleObj.fullName, styleObj.displayName); closeMenu(); }; - contentContainer.appendChild(item); - } - const separator = document.createElement("div"); - separator.style.cssText = `height:1px;background:#333;margin:5px 0;`; - contentContainer.appendChild(separator); + const item = document.createElement("div"); + item.style.cssText = `display:flex;justify-content:space-between;align-items:center;padding:10px 12px;cursor:pointer;border-bottom:1px solid #333;font-size:13px;background:#1a1a1a;transition:background-color 0.15s;`; + if (indentLevel > 0) { + item.style.paddingLeft = (12 + indentLevel * 16) + "px"; } - if (tree._folders && tree._folders.length > 0) { - for (const folderName of tree._folders) { - renderFolderItem(folderName, tree[folderName], contentContainer, 0); + const textSpan = document.createElement("span"); + textSpan.textContent = (isAdded ? "✓ " : "🎨 ") + (displayText || styleDisplayName); + textSpan.style.cssText = `flex-grow:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${isAdded ? '#4CAF50' : '#ddd'};`; + + const starSpan = document.createElement("span"); + const fileFavs = self.data.favorites[self.data.active_csv_file] || []; + const isFav = fileFavs.some(f => f.fullName === styleFullName); + starSpan.textContent = isFav ? "★" : "☆"; + starSpan.style.cssText = `color: ${isFav ? '#FFD700' : '#888'}; font-size: 16px; margin-left: 10px; flex-shrink: 0; cursor: pointer;`; + + starSpan.onclick = async (ev) => { + ev.stopPropagation(); + await self.toggleFavorite(styleFullName, styleDisplayName); + renderContent(); + }; + + item.onclick = (ev) => { + ev.stopPropagation(); + self.addStyleRow(styleFullName, styleDisplayName); + renderContent(); + }; + + item.onmouseover = () => item.style.backgroundColor = "#333"; + item.onmouseout = () => item.style.backgroundColor = "#1a1a1a"; + + item.appendChild(textSpan); + item.appendChild(starSpan); + container.appendChild(item); + } + + function renderContent() { + contentContainer.innerHTML = ""; + const query = currentSearchQuery; + + const matchesQuery = (displayName) => { + if (!query) return true; + return displayName.toLowerCase().includes(query); + }; + + if (query) { + const allMatchingStyles = []; + + function collectStyles(node, folderPrefix = "") { + if (node._styles) { + for (const styleObj of node._styles) { + if (matchesQuery(styleObj.displayName)) { + allMatchingStyles.push({ + fullName: styleObj.fullName, + displayName: styleObj.displayName, + folderPath: folderPrefix ? `${folderPrefix} / ${styleObj.displayName}` : styleObj.displayName + }); + } + } + } + if (node._folders) { + for (const folderName of node._folders) { + collectStyles(node[folderName], folderPrefix ? `${folderPrefix} / ${folderName}` : folderName); + } + } + } + + collectStyles(tree); + + if (allMatchingStyles.length === 0) { + const noResult = document.createElement("div"); + noResult.textContent = "No styles found"; + noResult.style.cssText = `padding:10px 12px;color:#888;font-size:12px;text-align:center;`; + contentContainer.appendChild(noResult); + return; + } + + for (const styleObj of allMatchingStyles) { + renderStyleItem(contentContainer, styleObj.fullName, styleObj.displayName, styleObj.folderPath); + } + } else { + if (tree._styles && tree._styles.length > 0) { + for (const styleObj of tree._styles) { + renderStyleItem(contentContainer, styleObj.fullName, styleObj.displayName); + } + const separator = document.createElement("div"); + separator.style.cssText = `height:1px;background:#333;margin:5px 0;`; + contentContainer.appendChild(separator); + } + + if (tree._folders && tree._folders.length > 0) { + for (const folderName of tree._folders) { + renderFolderItem(folderName, tree[folderName], contentContainer, 0); + } } } } @@ -476,10 +1181,10 @@ app.registerExtension({ folderHeader.onclick = (ev) => { ev.stopPropagation(); - const savedScroll = menu.scrollTop; + const savedScroll = contentContainer.scrollTop; expandedFolders[folderPath] = !isExpanded; renderContent(); - setTimeout(() => { menu.scrollTop = savedScroll; }, 0); + setTimeout(() => { contentContainer.scrollTop = savedScroll; }, 0); }; container.appendChild(folderHeader); @@ -487,14 +1192,7 @@ app.registerExtension({ if (isExpanded) { if (folderData._styles && folderData._styles.length > 0) { for (const styleObj of folderData._styles) { - const styleItem = document.createElement("div"); - styleItem.textContent = "🎨 " + styleObj.displayName; - styleItem.style.cssText = `padding:10px 12px;cursor:pointer;color:#ddd;border-bottom:1px solid #333;font-size:13px;padding-left:${12 + (level + 1) * 16}px;`; - styleItem.style.backgroundColor = "#1a1a1a"; - styleItem.onmouseover = () => styleItem.style.backgroundColor = "#333"; - styleItem.onmouseout = () => styleItem.style.backgroundColor = "#1a1a1a"; - styleItem.onclick = (ev) => { ev.stopPropagation(); self.addStyleRow(styleObj.fullName, styleObj.displayName); closeMenu(); }; - container.appendChild(styleItem); + renderStyleItem(container, styleObj.fullName, styleObj.displayName, null, level + 1); } } if (folderData._folders && folderData._folders.length > 0) { @@ -522,51 +1220,253 @@ app.registerExtension({ } if (currentScroll > 0) { - menu.scrollTop = currentScroll; + contentContainer.scrollTop = currentScroll; } + searchInput.focus(); }, 10); document.body.appendChild(menu); - menu.addEventListener("scroll", () => { - self.menuScrollPosition = menu.scrollTop; + contentContainer.addEventListener("scroll", () => { + self.menuScrollPosition = contentContainer.scrollTop; }); - document.addEventListener("pointerdown", closeOutside, true); document.addEventListener("keydown", closeEsc, true); }; + this.showSavePresetDialog = function(e) { + const existingDialog = document.getElementById("rayko-save-preset-dialog"); + if (existingDialog) existingDialog.remove(); + + const dialog = document.createElement("div"); + dialog.id = "rayko-save-preset-dialog"; + dialog.style.cssText = `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;`; + + const label = document.createElement("div"); + label.style.cssText = `color:#999;font-size:11px;margin-bottom:4px;`; + label.textContent = "Preset name:"; + + const input = document.createElement("input"); + input.style.cssText = `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 = document.createElement("div"); + btns.style.cssText = `display:flex;gap:5px;`; + + const okBtn = document.createElement("button"); + okBtn.style.cssText = `flex:1;padding:4px;background:#1a3a1a;color:#aaffaa;border:1px solid #4CAF50;border-radius:3px;cursor:pointer;font-size:11px;`; + okBtn.textContent = "OK"; + + const cancelBtn = document.createElement("button"); + cancelBtn.style.cssText = `flex:1;padding:4px;background:#2a2a2a;color:#ccc;border:1px solid #444;border-radius:3px;cursor:pointer;font-size:11px;`; + cancelBtn.textContent = "Cancel"; + + btns.appendChild(okBtn); + btns.appendChild(cancelBtn); + dialog.appendChild(label); + dialog.appendChild(input); + dialog.appendChild(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); + + const success = await self.savePreset(name); + if (success) { + console.log(`[Rayko] Preset "${name}" saved`); + } else { + alert("Failed to save preset"); + } + }; + + 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); + }; + + this.showLoadPresetMenu = async function(e) { + const existingMenu = document.getElementById("rayko-load-preset-menu"); + if (existingMenu) existingMenu.remove(); + + const menu = document.createElement("div"); + menu.id = "rayko-load-preset-menu"; + menu.style.cssText = `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.innerHTML = `
Loading...
`; + + let finalX = 100, finalY = 100; + if (e && e.clientX !== undefined && e.clientY !== undefined) { + finalX = e.clientX + 10; + finalY = e.clientY + 10; + } + + if (finalX + 250 > window.innerWidth) finalX = window.innerWidth - 260; + if (finalY + 300 > window.innerHeight) finalY = window.innerHeight - 310; + if (finalX < 10) finalX = 10; + if (finalY < 10) finalY = 10; + + menu.style.left = finalX + "px"; + menu.style.top = finalY + "px"; + + document.body.appendChild(menu); + + const clickTime = Date.now(); + const closeOutside = (ev) => { + if (Date.now() - clickTime < 300) return; + if (!menu.contains(ev.target)) { + menu.remove(); + document.removeEventListener("pointerdown", closeOutside, true); + } + }; + + setTimeout(() => { + document.addEventListener("pointerdown", closeOutside, true); + }, 50); + + try { + const presets = await self.loadPresetsList(); + menu.innerHTML = ""; + + if (presets.length === 0) { + menu.textContent = "No presets found"; + return; + } + + presets.forEach(name => { + const row = document.createElement("div"); + row.style.cssText = `display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-bottom:1px solid #333;`; + + const nameSpan = document.createElement("span"); + nameSpan.textContent = name; + nameSpan.style.cssText = `flex:1;cursor:pointer;color:#ccc;font-size:12px;`; + nameSpan.onmouseenter = () => nameSpan.style.background = "#3a3a3a"; + nameSpan.onmouseleave = () => nameSpan.style.background = "transparent"; + nameSpan.onclick = async (ev) => { + ev.stopPropagation(); + menu.remove(); + document.removeEventListener("pointerdown", closeOutside, true); + + const success = await self.loadPreset(name); + if (!success) { + alert("Failed to load preset"); + } + }; + + const deleteBtn = document.createElement("span"); + deleteBtn.textContent = "❌"; + deleteBtn.style.cssText = `cursor:pointer;margin-left:8px;font-size:14px;opacity:0.7;`; + deleteBtn.onmouseenter = () => { + deleteBtn.style.opacity = "1"; + deleteBtn.style.transform = "scale(1.2)"; + }; + deleteBtn.onmouseleave = () => { + deleteBtn.style.opacity = "0.7"; + deleteBtn.style.transform = "scale(1)"; + }; + deleteBtn.onclick = async (ev) => { + ev.stopPropagation(); + if (confirm(`Delete preset "${name}"?`)) { + const success = await self.deletePreset(name); + if (success) { + menu.remove(); + document.removeEventListener("pointerdown", closeOutside, true); + self.showLoadPresetMenu(e); + } else { + alert("Failed to delete preset"); + } + } + }; + + row.appendChild(nameSpan); + row.appendChild(deleteBtn); + menu.appendChild(row); + }); + } catch (err) { + menu.textContent = "Error loading presets"; + } + }; + this.addStyleRow = function(styleFullName, styleDisplayName) { const exists = self.data.styles.some(row => row.name === styleFullName && row.file === self.data.active_csv_file); - if (exists) { alert("⚠️ Already added!"); return; } + if (exists) return; self.data.styles.push({ name: styleFullName, display_name: styleDisplayName, file: self.data.active_csv_file, enabled: true }); + self.scrollOffset = 0; + self.manual_size = false; self.syncData(); - self.updateUI(); - self.menuPosition = null; - self.menuScrollPosition = 0; - }; - - this.syncData = function() { - if (this.hiddenWidget) { - this.hiddenWidget.value = JSON.stringify(this.data); - } - if (this.graph) { - this.graph.changeTracker?.dispatchEvent(new Event("change")); - } - }; - - this.updateUI = function() { - self.syncData(); - if (self.graph) { - self.graph.setDirtyCanvas(true, true); - self.graph.changeTracker?.dispatchEvent(new Event("change")); - } + + requestAnimationFrame(() => { + const startY = 80; + const rowH = self.rowHeight; + let y = startY; + y += rowH + 10; + y += rowH + 10; + y += rowH + 10; + y += rowH + 15; + y += rowH + 5; + y += self.toolbarHeight + 8; + + const desiredVisible = Math.min(self.data.styles.length, 10); + const calculatedHeight = Math.max(self.MIN_HEIGHT, y + desiredVisible * rowH + 10); + + self.setSize([self.targetWidth, calculatedHeight]); + + if (self.graph) { + self.graph.setDirtyCanvas(true, true); + setTimeout(() => self.graph.setDirtyCanvas(true, true), 50); + setTimeout(() => self.graph.setDirtyCanvas(true, true), 100); + setTimeout(() => self.graph.setDirtyCanvas(true, true), 150); + } + }); }; const onSerialize = this.onSerialize; this.onSerialize = function(o) { - self.syncData(); + this.syncData(); if (onSerialize) { return onSerialize.apply(this, arguments); } @@ -582,10 +1482,16 @@ app.registerExtension({ const restoredData = JSON.parse(this.hiddenWidget.value); if (restoredData) { this.data = { ...this.data, ...restoredData }; + this.expandedFolders = restoredData.expandedFolders || {}; + this.manual_size = restoredData.manual_size || false; + this.scrollOffset = restoredData.scrollOffset || 0; if (!this.data.csv_list.length && restoredData.csv_list) { this.data.csv_list = restoredData.csv_list; } - this.setSize([this.targetWidth, 300]); + this.setSize([ + Math.max(this.size[0], this.MIN_WIDTH), + Math.max(this.size[1], this.MIN_HEIGHT) + ]); this.graph?.setDirtyCanvas(true, true); } } catch (e) { @@ -595,6 +1501,7 @@ app.registerExtension({ }; this.loadCSVList(); + this.migrateFavorites(); return result; };