From 8daccc4f223974c242558acdee2fc5e71d60a617 Mon Sep 17 00:00:00 2001 From: VodooWaWa <77995526+VodooWaWa@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:10:13 +0800 Subject: [PATCH] Add files via upload --- Example image/note.png | Bin 0 -> 68035 bytes README.md | 25 +- README_EN.md | 37 ++- __init__.py | 333 ++++++++++++++++----------- web/css/model_preview.css | 329 +++++++++++++++++++++++--- web/visual_select_main.js | 473 ++++++++++++++++++++++++++++++++------ 6 files changed, 950 insertions(+), 247 deletions(-) create mode 100644 Example image/note.png diff --git a/Example image/note.png b/Example image/note.png new file mode 100644 index 0000000000000000000000000000000000000000..7ad41e7695da1f2b881d24c3f288720547ede09a GIT binary patch literal 68035 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 **提示**:所有的设置都会自动保存在插件目录下的 `config.json` 文件中,支持持久化。 diff --git a/README_EN.md b/README_EN.md index ac17def..8d5ab15 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,8 +1,18 @@ -# ComfyUI Visual Select v1.0 +# ComfyUI Visual Select v1.1.0 ComfyUI Visual Select is a front-end extension for ComfyUI designed to enhance the model selection experience. By overriding the native model dropdown menus, it provides an intuitive visual model browser with preview images and detailed information, helping users manage and select models more efficiently. +ComfyUI Visual Select 中文版:[README.md](https://github.com/VodooWaWa/Comfyui-Visual-Select/blob/main/README.md) + +Cover images and metadata display require a cover image and an info metadata file with the same base name as the model file in the model folder. You can manually pick any cover image and rename it to match the model filename to make it show up. However, the info metadata file requires parsing JSON returned by the Civitai API. + +**You can also pair this extension with my other repository tool to get a perfect display experience by generating covers and metadata while downloading and managing models.** +https://github.com/VodooWaWa/Civitai-Download-Tool +(Sorry, this tool is primarily designed for Chinese users and the Chinese network environment, so it does not support an English interface. If you really need English support, please submit an issue to let us know. 🙏) + +Video tutorial (Bilibili): https://www.bilibili.com/video/BV1kfDTBXEad/ + ## 🎨 UI Preview -![image](https://github.com/VodooWaWa/Comfyui-Visual-Select/blob/main/Example%20image/VisualSelect.png) +![image](/Example%20image/VisualSelect.png) ## ✨ Key Features * **🎨 Visual Model Browsing**: Replaces cumbersome text dropdowns with an intuitive card grid layout, supporting model cover image display. @@ -13,6 +23,7 @@ ComfyUI Visual Select is a front-end extension for ComfyUI designed to enhance t * Filter by FP Format (model precision). * Filter by content safety level (SFW/NSFW). * **📄 HTML Detail Preview**: Renders HTML detail pages for matching model names directly in a popup, with full support for local image resource loading. +* **📝 Notes**: Create multiple notes per model (title/content) with newline support, one-click copy, drag-and-drop reordering, and auto-save to a `*.notes.json` file alongside the model. * **🌐 Internationalization**: Built-in Chinese and English interface, switchable seamlessly in settings. * **⚙️ Highly Customizable (Persistent Configuration)**: * Customizable trigger keywords. @@ -20,6 +31,11 @@ ComfyUI Visual Select is a front-end extension for ComfyUI designed to enhance t * Settings auto-save and persist even in incognito mode. * **🧩 Standalone Model Browser Node**: Provides a dedicated `🎨 Visual Select Model Browser` node for browsing and selecting any type of model independently. +## 🆕 Updates + +* **Removed backdrop-filter**: Overlay blur is no longer used in the main dialog, details dialog, and notes dialog to reduce rendering overhead and improve responsiveness. +* **Added Notes feature**: A new “Notes” entry is available in the model card actions, supporting add/delete/edit/copy/drag reorder with auto-save to `*.notes.json` next to the model. + ## 📦 Installation 1. Navigate to your ComfyUI custom nodes directory (`ComfyUI/custom_nodes/`). 2. Clone or extract this project into the directory, ensuring the folder name is `Comfyui-Visual-Select`. @@ -38,12 +54,19 @@ You can also add a dedicated browser node to the canvas: 3. Click `selected_model` to open the corresponding visual interface for selection. 4. Connect the output to any node that accepts a model path string. +### 📝 Notes +Click the “Notes” button on a model card to open the notes dialog. It supports multiple entries, title/content editing (with newlines), one-click copy, drag-and-drop sorting, and auto-save to a `*.notes.json` file next to the model. + +![note](/Example%20image/note.png) + ## ⚙️ Settings Click the gear icon in the top-right corner of the ComfyUI interface to access **Settings**. The following options are available: -* **🎨 Visual Select - 1. Enable Visual Selector**: Master toggle for the extension. -* **🎨 Visual Select - 2. Trigger Keywords**: Floating button only appears for node properties containing these keywords (e.g., ckpt, lora). Separate with commas. -* **🎨 Visual Select - 3. Exclude Keywords**: Force native dropdowns for node properties containing these keywords. Defaults to `model_type`. -* **🎨 Visual Select - 4. Language**: Switch interface language (中文 / English). +The following options are under the `🎨Visual Select` group: + +* **Enable Visual Selector**: Master toggle for the extension. +* **Listening Keywords (comma-separated)**: The floating button only appears for node properties containing these keywords (e.g., ckpt, lora). Separate with commas. +* **Exclude Keywords (comma-separated)**: Force native dropdowns for node properties containing these keywords. Defaults to `model_type`. +* **Language**: Switch interface language (中文 / English). > **Tip**: All settings are auto-saved in `config.json` in the extension directory and persist across sessions. @@ -55,4 +78,4 @@ The extension automatically scans files with the **same name** as the model for ## 🤝 Compatibility * Fully compatible with the classic LiteGraph rendering mode (V1 Canvas). -* Compatible with ComfyUI's latest Vue component architecture (Node 2.0 Canvas). \ No newline at end of file +* Compatible with ComfyUI's latest Vue component architecture (Node 2.0 Canvas). diff --git a/__init__.py b/__init__.py index ee55adf..9a16c00 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,7 @@ import os import json import urllib.parse import re -import time +import time from aiohttp import web # Define the web directory for frontend files @@ -54,135 +54,139 @@ DEFAULT_CONFIG = { "enabled": True, "intercept_types": "ckpt,model,lora,vae,unet,controlnet,checkpoint", "intercept_excludes": "model_type", - "language": "zh-CN", - "nsfw_blur": True + "language": "zh-CN", + "nsfw_blur": True } -try: - from comfy.model_downloader import get_full_path_or_raise as _comfy_get_full_path_or_raise - from comfy.model_downloader import get_filename_list as _comfy_get_filename_list -except Exception: - _comfy_get_full_path_or_raise = None - _comfy_get_filename_list = None - -_MODEL_TYPE_ALIASES = { - "checkpoint": "checkpoints", - "ckpt": "checkpoints", - "lora": "loras", - "control": "controlnet", -} - -_BASENAME_INDEX_CACHE = {} -_BASENAME_INDEX_CACHE_TS = {} -_BASENAME_INDEX_CACHE_TTL_SEC = 30 - -def _normalize_model_type(model_type: str) -> str: - if not model_type: - return "" - mt = str(model_type).strip() - if mt in folder_paths.folder_names_and_paths: - return mt - mt_lower = mt.lower() - if mt_lower in _MODEL_TYPE_ALIASES: - mapped = _MODEL_TYPE_ALIASES[mt_lower] - if mapped in folder_paths.folder_names_and_paths: - return mapped - for k, v in _MODEL_TYPE_ALIASES.items(): - if k in mt_lower and v in folder_paths.folder_names_and_paths: - return v - return mt - -def _get_filename_list(folder_type: str): - try: - return folder_paths.get_filename_list(folder_type) - except Exception: - if _comfy_get_filename_list: - return _comfy_get_filename_list(folder_type) - return [] - -def _get_basename_index(folder_type: str): - now = time.time() - ts = _BASENAME_INDEX_CACHE_TS.get(folder_type, 0) - if folder_type in _BASENAME_INDEX_CACHE and (now - ts) < _BASENAME_INDEX_CACHE_TTL_SEC: - return _BASENAME_INDEX_CACHE[folder_type] - - index = {} - names = _get_filename_list(folder_type) or [] - for n in names: - if not isinstance(n, str) or not n: - continue - base = os.path.basename(n).lower() - if base not in index: - index[base] = n - - _BASENAME_INDEX_CACHE[folder_type] = index - _BASENAME_INDEX_CACHE_TS[folder_type] = now - return index - -def _resolve_model_full_path(model_type: str, model_name: str): - if not model_name or not isinstance(model_name, str): - return None - if os.path.isabs(model_name): - return None - - mt = _normalize_model_type(model_type) - candidate_types = [] - if mt and mt in folder_paths.folder_names_and_paths: - candidate_types.append(mt) - - for folder_name in folder_paths.folder_names_and_paths: - if folder_name not in candidate_types: - candidate_types.append(folder_name) - - for folder_name in candidate_types: - try: - path = folder_paths.get_full_path(folder_name, model_name) - if path and os.path.exists(path): - return path - except Exception: - pass - if _comfy_get_full_path_or_raise: - try: - path = _comfy_get_full_path_or_raise(folder_name, model_name) - if path and os.path.exists(path): - return path - except Exception: - pass - - if ("/" not in model_name) and ("\\" not in model_name): - model_name_lower = model_name.lower() - for folder_name in candidate_types: - idx = _get_basename_index(folder_name) - rel = idx.get(model_name_lower) - if not rel: - continue - try: - path = folder_paths.get_full_path(folder_name, rel) - if path and os.path.exists(path): - return path - except Exception: - pass - if _comfy_get_full_path_or_raise: - try: - path = _comfy_get_full_path_or_raise(folder_name, rel) - if path and os.path.exists(path): - return path - except Exception: - pass - - return None - +try: + from comfy.model_downloader import get_full_path_or_raise as _comfy_get_full_path_or_raise + from comfy.model_downloader import get_filename_list as _comfy_get_filename_list +except Exception: + _comfy_get_full_path_or_raise = None + _comfy_get_filename_list = None + +_MODEL_TYPE_ALIASES = { + "checkpoint": "checkpoints", + "ckpt": "checkpoints", + "lora": "loras", + "control": "controlnet", +} + +_BASENAME_INDEX_CACHE = {} +_BASENAME_INDEX_CACHE_TS = {} +_BASENAME_INDEX_CACHE_TTL_SEC = 30 + +def _normalize_model_type(model_type: str) -> str: + if not model_type: + return "" + mt = str(model_type).strip() + if mt in folder_paths.folder_names_and_paths: + return mt + mt_lower = mt.lower() + if mt_lower in _MODEL_TYPE_ALIASES: + mapped = _MODEL_TYPE_ALIASES[mt_lower] + if mapped in folder_paths.folder_names_and_paths: + return mapped + for k, v in _MODEL_TYPE_ALIASES.items(): + if k in mt_lower and v in folder_paths.folder_names_and_paths: + return v + return mt + +def _get_filename_list(folder_type: str): + try: + return folder_paths.get_filename_list(folder_type) + except Exception: + if _comfy_get_filename_list: + return _comfy_get_filename_list(folder_type) + return [] + +def _get_basename_index(folder_type: str): + now = time.time() + ts = _BASENAME_INDEX_CACHE_TS.get(folder_type, 0) + if folder_type in _BASENAME_INDEX_CACHE and (now - ts) < _BASENAME_INDEX_CACHE_TTL_SEC: + return _BASENAME_INDEX_CACHE[folder_type] + + index = {} + names = _get_filename_list(folder_type) or [] + for n in names: + if not isinstance(n, str) or not n: + continue + base = os.path.basename(n).lower() + if base not in index: + index[base] = n + + _BASENAME_INDEX_CACHE[folder_type] = index + _BASENAME_INDEX_CACHE_TS[folder_type] = now + return index + +def _resolve_model_full_path(model_type: str, model_name: str): + if not model_name or not isinstance(model_name, str): + return None + if os.path.isabs(model_name): + return None + + mt = _normalize_model_type(model_type) + candidate_types = [] + if mt and mt in folder_paths.folder_names_and_paths: + candidate_types.append(mt) + + for folder_name in folder_paths.folder_names_and_paths: + if folder_name not in candidate_types: + candidate_types.append(folder_name) + + for folder_name in candidate_types: + try: + path = folder_paths.get_full_path(folder_name, model_name) + if path and os.path.exists(path): + return path + except Exception: + pass + if _comfy_get_full_path_or_raise: + try: + path = _comfy_get_full_path_or_raise(folder_name, model_name) + if path and os.path.exists(path): + return path + except Exception: + pass + + if ("/" not in model_name) and ("\\" not in model_name): + model_name_lower = model_name.lower() + for folder_name in candidate_types: + idx = _get_basename_index(folder_name) + rel = idx.get(model_name_lower) + if not rel: + continue + try: + path = folder_paths.get_full_path(folder_name, rel) + if path and os.path.exists(path): + return path + except Exception: + pass + if _comfy_get_full_path_or_raise: + try: + path = _comfy_get_full_path_or_raise(folder_name, rel) + if path and os.path.exists(path): + return path + except Exception: + pass + + return None + +def _notes_file_path(model_full_path: str) -> str: + base_path = os.path.splitext(model_full_path)[0] + return base_path + ".notes.json" + def load_config(): if os.path.exists(CONFIG_PATH): try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: - cfg = json.load(f) - if isinstance(cfg, dict): - for k, v in DEFAULT_CONFIG.items(): - if k not in cfg: - cfg[k] = v - return cfg - return DEFAULT_CONFIG + cfg = json.load(f) + if isinstance(cfg, dict): + for k, v in DEFAULT_CONFIG.items(): + if k not in cfg: + cfg[k] = v + return cfg + return DEFAULT_CONFIG except Exception as e: print(f"[Visual_Select] Error loading config: {e}") return DEFAULT_CONFIG @@ -213,8 +217,8 @@ if hasattr(server.PromptServer, "instance"): config["intercept_excludes"] = data["intercept_excludes"] if "language" in data: config["language"] = data["language"] - if "nsfw_blur" in data: - config["nsfw_blur"] = bool(data["nsfw_blur"]) + if "nsfw_blur" in data: + config["nsfw_blur"] = bool(data["nsfw_blur"]) save_config(config) return web.json_response({"status": "success"}) except Exception as e: @@ -230,7 +234,7 @@ if hasattr(server.PromptServer, "instance"): if not model_name: return web.Response(status=400, text="Model name required") - full_path = _resolve_model_full_path(model_type, model_name) + full_path = _resolve_model_full_path(model_type, model_name) if not full_path: return web.Response(status=404, text="Model not found") @@ -282,7 +286,7 @@ if hasattr(server.PromptServer, "instance"): if not model_name or not file_name: return web.Response(status=400, text="Missing parameters") - full_path = _resolve_model_full_path(model_type, model_name) + full_path = _resolve_model_full_path(model_type, model_name) if not full_path: return web.Response(status=404, text="Model not found") @@ -309,13 +313,13 @@ if hasattr(server.PromptServer, "instance"): async def get_model_preview_info(request): data = await request.json() model_names = data.get("names", []) - model_type = data.get("type", "") + model_type = data.get("type", "") result = {} extensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"] for model_name in model_names: - full_path = _resolve_model_full_path(model_type, model_name) + full_path = _resolve_model_full_path(model_type, model_name) if full_path: base_path = os.path.splitext(full_path)[0] @@ -360,6 +364,75 @@ if hasattr(server.PromptServer, "instance"): } return web.json_response(result) + + @server.PromptServer.instance.routes.get("/visual_select/notes") + async def get_model_notes(request): + model_name = request.query.get("name", "") + model_type = request.query.get("type", "") + if not model_name: + return web.json_response({"items": []}) + + full_path = _resolve_model_full_path(model_type, model_name) + if not full_path: + return web.Response(status=404, text="Model not found") + + notes_path = _notes_file_path(full_path) + if not os.path.exists(notes_path): + return web.json_response({"items": []}) + + try: + with open(notes_path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and isinstance(data.get("items"), list): + return web.json_response({"items": data.get("items", [])}) + except Exception: + pass + + return web.json_response({"items": []}) + + @server.PromptServer.instance.routes.post("/visual_select/notes") + async def save_model_notes(request): + try: + data = await request.json() + except Exception: + return web.json_response({"status": "error", "message": "Invalid JSON"}, status=400) + + model_name = data.get("name", "") + model_type = data.get("type", "") + items = data.get("items", []) + + if not model_name: + return web.json_response({"status": "error", "message": "Model name required"}, status=400) + + full_path = _resolve_model_full_path(model_type, model_name) + if not full_path: + return web.json_response({"status": "error", "message": "Model not found"}, status=404) + + if not isinstance(items, list): + return web.json_response({"status": "error", "message": "Items must be a list"}, status=400) + + normalized_items = [] + for it in items: + if not isinstance(it, dict): + continue + item_id = str(it.get("id", "")).strip() + if not item_id: + item_id = str(int(time.time() * 1000)) + title = it.get("title", "") + content = it.get("content", "") + if not isinstance(title, str): + title = str(title) + if not isinstance(content, str): + content = str(content) + normalized_items.append({"id": item_id, "title": title, "content": content}) + + notes_path = _notes_file_path(full_path) + try: + with open(notes_path, "w", encoding="utf-8") as f: + json.dump({"version": 1, "items": normalized_items}, f, ensure_ascii=False, indent=2) + return web.json_response({"status": "success"}) + except Exception as e: + return web.json_response({"status": "error", "message": str(e)}, status=500) @server.PromptServer.instance.routes.get("/visual_select/get_models") async def get_models_by_type(request): diff --git a/web/css/model_preview.css b/web/css/model_preview.css index 3261e3d..e8bd92c 100644 --- a/web/css/model_preview.css +++ b/web/css/model_preview.css @@ -19,7 +19,6 @@ width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(2px); } .vs-modal { @@ -156,35 +155,35 @@ flex-direction: column; } -.vs-toolbar { - display: flex; - gap: 10px; - padding: 12px 20px; - border-bottom: 1px solid #333; - flex-wrap: wrap; - align-items: center; - background: #1a1a1a; -} - -.vs-nsfw-blur-toggle { - margin-left: auto; - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - background: #111; - border: 1px solid #444; - border-radius: 4px; - color: #ddd; - font-size: 0.85rem; - cursor: pointer; - user-select: none; -} - -.vs-nsfw-blur-toggle input { - margin: 0; -} - +.vs-toolbar { + display: flex; + gap: 10px; + padding: 12px 20px; + border-bottom: 1px solid #333; + flex-wrap: wrap; + align-items: center; + background: #1a1a1a; +} + +.vs-nsfw-blur-toggle { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: #111; + border: 1px solid #444; + border-radius: 4px; + color: #ddd; + font-size: 0.85rem; + cursor: pointer; + user-select: none; +} + +.vs-nsfw-blur-toggle input { + margin: 0; +} + .vs-top-bar { display: flex; gap: 10px; @@ -282,17 +281,17 @@ width: 100%; height: 100%; object-fit: cover; - transition: transform 0.3s ease, filter 0.2s ease; + transition: transform 0.3s ease, filter 0.2s ease; } .vs-card:hover .vs-card-img img { transform: scale(1.05); } -.vs-card-img.vs-nsfw-blur img { - filter: blur(14px) brightness(0.7); -} - +.vs-card-img.vs-nsfw-blur img { + filter: blur(14px) brightness(0.7); +} + .vs-no-image { color: #555; font-size: 0.9rem; @@ -313,6 +312,7 @@ word-break: break-all; display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.2; @@ -372,6 +372,18 @@ min-height: 72px; /* Force consistent height for actions area */ } +.vs-card-actions-row { + display: flex; + gap: 8px; + width: 100%; +} + +.vs-card-actions-row .vs-note-btn, +.vs-card-actions-row .vs-details-btn { + flex: 1; + width: auto; +} + .vs-select-btn { width: 100%; padding: 6px 12px; @@ -409,6 +421,27 @@ background: #218838; } +.vs-note-btn { + width: 100%; + padding: 6px 12px; + background: #5a6268; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + font-size: 13.3px; + transition: background 0.2s, box-shadow 0.2s; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.vs-note-btn:hover { + background: #495057; +} + .vs-details-dialog { position: fixed; top: 0; @@ -426,4 +459,228 @@ height: 100%; border: none; background: #fff; /* HTML details might expect a light background by default */ -} +} + +.vs-notes-dialog { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 10000; + display: flex; + justify-content: center; + align-items: center; +} + +.vs-notes-dialog .vs-overlay { + backdrop-filter: none; +} + +.vs-notes-toolbar { + display: flex; + gap: 10px; + padding: 10px 20px; + border-bottom: 1px solid #333; + background: #1a1a1a; +} + +.vs-notes-toolbar .vs-note-btn, +.vs-notes-toolbar .vs-details-btn { + width: auto; + flex: 0 0 auto; + padding: 8px 18px; + min-width: 96px; +} + +.vs-notes-toolbar .vs-note-btn:disabled, +.vs-notes-toolbar .vs-details-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.vs-notes-toolbar .vs-notes-del { + background: #c82333; +} + +.vs-notes-toolbar .vs-notes-del:hover { + background: #b21f2d; +} + +.vs-notes-body { + display: flex; + flex: 1; + overflow: hidden; + background: #222; + flex-direction: column; +} + +.vs-notes-top { + display: flex; + flex: 1; + min-height: 0; +} + +.vs-notes-list { + width: 280px; + border-right: 1px solid #333; + background: #1e1e1e; + overflow-y: auto; + padding: 10px 0; +} + +.vs-notes-empty { + padding: 12px 16px; + color: #888; + font-size: 0.9rem; +} + +.vs-notes-item { + padding: 10px 16px; + cursor: pointer; + color: #ccc; + border-left: 3px solid transparent; + transition: background 0.2s, color 0.2s; + word-break: break-word; + user-select: none; +} + +.vs-notes-item[draggable="true"] { + cursor: grab; +} + +.vs-notes-item.dragging { + opacity: 0.6; + cursor: grabbing; +} + +.vs-notes-item.drag-over { + background: #2a2a2a; +} + +.vs-notes-item:hover { + background: #333; + color: #fff; +} + +.vs-notes-item.active { + background: #444; + color: #fff; + font-weight: bold; + border-left-color: #007bff; +} + +.vs-notes-editor { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + min-height: 0; +} + +.vs-note-title-input { + width: 100%; + padding: 10px 12px; + border-radius: 4px; + border: 1px solid #444; + background: #111; + color: #eee; + font-size: 0.95rem; +} + +.vs-note-title-input:focus { + outline: none; + border-color: #007bff; +} + +.vs-note-content-wrap { + position: relative; + flex: 1; + display: flex; +} + +.vs-note-content-input { + width: 100%; + resize: none; + padding: 10px 44px 10px 12px; + border-radius: 4px; + border: 1px solid #444; + background: #111; + color: #eee; + font-size: 0.9rem; + line-height: 1.35; + flex: 1; +} + +.vs-note-content-input:focus { + outline: none; + border-color: #007bff; +} + +.vs-note-copy-btn { + position: absolute; + top: 10px; + right: 10px; + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid #444; + background: #1a1a1a; + color: #ddd; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.vs-note-copy-btn:hover { + background: #2a2a2a; + color: #fff; +} + +.vs-notes-preview { + height: 50%; + border-top: 1px solid #333; + background: #1a1a1a; + overflow-y: auto; + padding: 10px; +} + +.vs-notes-preview-empty { + padding: 12px 6px; + color: #888; + font-size: 0.9rem; +} + +.vs-notes-preview-item { + border: 1px solid #2a2a2a; + background: #111; + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 10px; + cursor: pointer; +} + +.vs-notes-preview-item:hover { + border-color: #3a3a3a; + background: #121212; +} + +.vs-notes-preview-item.active { + border-color: #007bff; +} + +.vs-notes-preview-title { + font-weight: 700; + color: #eee; + margin-bottom: 6px; + word-break: break-word; +} + +.vs-notes-preview-content { + color: #bbb; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.35; +} diff --git a/web/visual_select_main.js b/web/visual_select_main.js index c6ef6a8..a3eb3d9 100644 --- a/web/visual_select_main.js +++ b/web/visual_select_main.js @@ -19,7 +19,7 @@ window._vs_config = { intercept_types: "ckpt,model,lora,vae,unet,controlnet,checkpoint", intercept_excludes: "model_type", language: "zh-CN", - nsfw_blur: true + nsfw_blur: true, }; // I18n strings @@ -38,6 +38,12 @@ const I18N = { noModels: "No models match the criteria", noImage: "No Image", details: "Details", + note: "Notes", + notesTitle: "Notes", + add: "Add", + del: "Delete", + copy: "Copy", + emptyNotes: "No notes yet", select: "Select", all: "All", withInfo: "With Info", @@ -60,6 +66,12 @@ const I18N = { noModels: "没有符合条件的模型", noImage: "无图片", details: "详情", + note: "备注", + notesTitle: "备注", + add: "新增", + del: "删除", + copy: "复制", + emptyNotes: "暂无备注", select: "选择", all: "全部", withInfo: "有信息", @@ -167,6 +179,307 @@ function showDetailsDialog(name) { dialog.querySelector(".vs-overlay").onclick = () => document.body.removeChild(dialog); } +function _escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function _cssEscape(value) { + if (window.CSS && typeof window.CSS.escape === "function") { + return window.CSS.escape(value); + } + return String(value).replaceAll('"', '\\"'); +} + +function _copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + const ta = document.createElement("textarea"); + ta.value = text || ""; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand("copy"); + } finally { + document.body.removeChild(ta); + } + return Promise.resolve(); +} + +async function showNotesDialog(modelPath, displayName) { + const encodedName = encodeURIComponent(modelPath); + const type = window._vs_current_model_type || ""; + const encodedType = encodeURIComponent(type); + const url = `/visual_select/notes?name=${encodedName}&type=${encodedType}`; + + const dialog = document.createElement("div"); + dialog.className = "vs-notes-dialog"; + dialog.innerHTML = ` +

+
+
+

📝 ${t('notesTitle')}: ${_escapeHtml(displayName || modelPath.split('/').pop())}

+
×
+
+
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+
+
+ `; + + document.body.appendChild(dialog); + + const close = () => document.body.removeChild(dialog); + dialog.querySelector(".vs-close").onclick = close; + dialog.querySelector(".vs-overlay").onclick = close; + + const listEl = dialog.querySelector(".vs-notes-list"); + const addBtn = dialog.querySelector(".vs-notes-add"); + const delBtn = dialog.querySelector(".vs-notes-del"); + const titleInput = dialog.querySelector(".vs-note-title-input"); + const contentInput = dialog.querySelector(".vs-note-content-input"); + const copyBtn = dialog.querySelector(".vs-note-copy-btn"); + const previewEl = dialog.querySelector(".vs-notes-preview"); + + const state = { + items: [], + selectedId: null, + saving: false, + dragId: null + }; + + function renderList() { + listEl.innerHTML = ""; + if (!state.items.length) { + const empty = document.createElement("div"); + empty.className = "vs-notes-empty"; + empty.innerText = t("emptyNotes"); + listEl.appendChild(empty); + } else { + for (const item of state.items) { + const row = document.createElement("div"); + row.className = "vs-notes-item" + (item.id === state.selectedId ? " active" : ""); + row.setAttribute("data-id", item.id); + row.setAttribute("draggable", "true"); + row.innerText = item.title || ""; + row.onclick = () => { + state.selectedId = item.id; + renderAll(); + }; + row.ondragstart = (e) => { + state.dragId = item.id; + try { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", item.id); + } catch (err) {} + row.classList.add("dragging"); + }; + row.ondragend = () => { + state.dragId = null; + listEl.querySelectorAll(".vs-notes-item.dragging").forEach(el => el.classList.remove("dragging")); + listEl.querySelectorAll(".vs-notes-item.drag-over").forEach(el => el.classList.remove("drag-over")); + }; + row.ondragover = (e) => { + e.preventDefault(); + row.classList.add("drag-over"); + try { e.dataTransfer.dropEffect = "move"; } catch (err) {} + }; + row.ondragleave = () => { + row.classList.remove("drag-over"); + }; + row.ondrop = async (e) => { + e.preventDefault(); + row.classList.remove("drag-over"); + const fromId = state.dragId || (() => { + try { return e.dataTransfer.getData("text/plain"); } catch (err) { return ""; } + })(); + const toId = item.id; + if (!fromId || fromId === toId) return; + const fromIndex = state.items.findIndex(x => x.id === fromId); + const toIndex = state.items.findIndex(x => x.id === toId); + if (fromIndex === -1 || toIndex === -1) return; + + const next = state.items.slice(); + const [moved] = next.splice(fromIndex, 1); + const insertIndex = fromIndex < toIndex ? toIndex - 1 : toIndex; + next.splice(insertIndex, 0, moved); + state.items = next; + renderAll(); + await saveNotes(); + }; + listEl.appendChild(row); + } + } + delBtn.disabled = !state.selectedId; + } + + function renderPreview() { + previewEl.innerHTML = ""; + if (!state.items.length) { + const empty = document.createElement("div"); + empty.className = "vs-notes-preview-empty"; + empty.innerText = t("emptyNotes"); + previewEl.appendChild(empty); + return; + } + + for (const item of state.items) { + const wrap = document.createElement("div"); + wrap.className = "vs-notes-preview-item" + (item.id === state.selectedId ? " active" : ""); + wrap.setAttribute("data-id", item.id); + wrap.onclick = () => { + state.selectedId = item.id; + renderAll(); + }; + + const title = document.createElement("div"); + title.className = "vs-notes-preview-title"; + title.innerText = item.title || ""; + + const content = document.createElement("div"); + content.className = "vs-notes-preview-content"; + content.innerText = item.content || ""; + + wrap.appendChild(title); + wrap.appendChild(content); + previewEl.appendChild(wrap); + } + } + + function selectedItem() { + if (!state.selectedId) return null; + return state.items.find(i => i.id === state.selectedId) || null; + } + + function renderEditor() { + const item = selectedItem(); + if (!item) { + titleInput.value = ""; + contentInput.value = ""; + titleInput.disabled = true; + contentInput.disabled = true; + copyBtn.disabled = true; + return; + } + titleInput.disabled = false; + contentInput.disabled = false; + copyBtn.disabled = false; + titleInput.value = item.title || ""; + contentInput.value = item.content || ""; + } + + function renderAll() { + renderList(); + renderEditor(); + renderPreview(); + } + + async function saveNotes() { + if (state.saving) return; + state.saving = true; + try { + await fetch("/visual_select/notes", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + name: modelPath, + type, + items: state.items + }) + }); + } finally { + state.saving = false; + } + } + + addBtn.onclick = async () => { + const id = String(Date.now()) + "_" + Math.random().toString(16).slice(2); + const newItem = {id, title: t("note"), content: ""}; + state.items = [newItem, ...state.items]; + state.selectedId = id; + renderAll(); + titleInput.focus(); + titleInput.select(); + await saveNotes(); + }; + + delBtn.onclick = async () => { + if (!state.selectedId) return; + state.items = state.items.filter(i => i.id !== state.selectedId); + state.selectedId = state.items.length ? state.items[0].id : null; + renderAll(); + await saveNotes(); + }; + + titleInput.addEventListener("input", () => { + const item = selectedItem(); + if (!item) return; + item.title = titleInput.value; + const row = listEl.querySelector(`.vs-notes-item[data-id="${_cssEscape(item.id)}"]`); + if (row) row.innerText = item.title || ""; + const p = previewEl.querySelector(`.vs-notes-preview-item[data-id="${_cssEscape(item.id)}"] .vs-notes-preview-title`); + if (p) p.innerText = item.title || ""; + }); + + contentInput.addEventListener("input", () => { + const item = selectedItem(); + if (!item) return; + item.content = contentInput.value; + const p = previewEl.querySelector(`.vs-notes-preview-item[data-id="${_cssEscape(item.id)}"] .vs-notes-preview-content`); + if (p) p.innerText = item.content || ""; + }); + + titleInput.addEventListener("blur", saveNotes); + contentInput.addEventListener("blur", saveNotes); + + copyBtn.onclick = async () => { + const item = selectedItem(); + if (!item) return; + await _copyToClipboard(item.content || ""); + }; + + try { + const res = await fetch(url); + const data = await res.json(); + if (data && Array.isArray(data.items)) { + state.items = data.items; + } else { + state.items = []; + } + state.selectedId = state.items.length ? state.items[0].id : null; + } catch (e) { + state.items = []; + state.selectedId = null; + } + + renderAll(); +} + function buildUI(dialog, widget, node, models, info) { dialog.querySelector(".vs-loading").style.display = "none"; @@ -319,10 +632,9 @@ function buildUI(dialog, widget, node, models, info) { imgHtml = `${m.name}`; } - let detailsBtn = '
'; + let detailsBtn = ""; if (m.info.has_html) { const encodedName = encodeURIComponent(m.fullPath); - // Changed from tag to `; } @@ -345,6 +657,13 @@ function buildUI(dialog, widget, node, models, info) { } badgesHtml += ''; + const actionRowHtml = ` +
+ + ${detailsBtn} +
+ `; + card.innerHTML = `
${imgHtml}
@@ -356,7 +675,7 @@ function buildUI(dialog, widget, node, models, info) { - ${detailsBtn} + ${actionRowHtml}
`; @@ -387,6 +706,17 @@ function buildUI(dialog, widget, node, models, info) { } }; }); + + gridEl.querySelectorAll(".vs-note-btn").forEach(btn => { + btn.onclick = async (e) => { + e.stopPropagation(); + const name = btn.getAttribute("data-name"); + const display = btn.getAttribute("data-display"); + if (name) { + await showNotesDialog(name, display); + } + }; + }); // Add double click on card image/name/filename to select gridEl.querySelectorAll(".vs-card-img, .vs-card-name, .vs-card-filename").forEach(el => { @@ -764,72 +1094,79 @@ async function showAdvancedBrowser(widget, node, forceType) { app.registerExtension({ name: "visual_select.model_preview", + settings: [ + { + id: "VisualSelect.Settings.Enabled", + name: "开启视图选择器", + category: ["🎨Visual Select", "基础", "总开关"], + type: "boolean", + defaultValue: true, + tooltip: "关闭后将不拦截下拉框并禁用视图选择器弹窗", + onChange: (val) => { + window._vs_config.enabled = val; + fetch("/visual_select/config", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({enabled: val}) + }).catch(e => console.error(e)); + } + }, + { + id: "VisualSelect.Settings.InterceptTypes", + name: "监听关键字(逗号分隔)", + category: ["🎨Visual Select", "基础", "监听关键字"], + type: "text", + defaultValue: "ckpt,model,lora,vae,unet,controlnet,checkpoint", + tooltip: "只有当节点属性名包含这些关键字时才弹出悬浮按钮", + onChange: (val) => { + window._vs_config.intercept_types = val; + fetch("/visual_select/config", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({intercept_types: val}) + }).catch(e => console.error(e)); + } + }, + { + id: "VisualSelect.Settings.InterceptExcludes", + name: "排除关键字(逗号分隔)", + category: ["🎨Visual Select", "基础", "排除关键字"], + type: "text", + defaultValue: "model_type", + tooltip: "当节点属性名包含这些关键字时将强制使用原生下拉框(不拦截)", + onChange: (val) => { + window._vs_config.intercept_excludes = val; + fetch("/visual_select/config", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({intercept_excludes: val}) + }).catch(e => console.error(e)); + } + }, + { + id: "VisualSelect.Settings.Language", + name: "语言 (Language)", + category: ["🎨Visual Select", "界面", "语言"], + type: "combo", + options: [ + { text: "中文 (Chinese)", value: "zh-CN" }, + { text: "English", value: "en-US" } + ], + defaultValue: "zh-CN", + tooltip: "切换插件界面语言", + onChange: (val) => { + window._vs_config.language = val; + fetch("/visual_select/config", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({language: val}) + }).catch(e => console.error(e)); + } + } + ], async setup() { console.log("🚀 [Visual_Select] Main Extension setup executed!"); - - if (app.ui && app.ui.settings) { - app.ui.settings.addSetting({ - id: "VisualSelect.Settings.Language", - name: "🎨 Visual Select - 4. 语言 (Language)", - type: "combo", - options: (value) => [ - { value: "zh-CN", text: "中文 (Chinese)", selected: value === "zh-CN" }, - { value: "en-US", text: "English", selected: value === "en-US" } - ], - defaultValue: "zh-CN", - onChange: (val) => { - window._vs_config.language = val; - fetch("/visual_select/config", { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({language: val}) - }).catch(e => console.error(e)); - } - }); - app.ui.settings.addSetting({ - id: "VisualSelect.Settings.InterceptExcludes", - name: "🎨 Visual Select - 3. 排除关键字配置 (用逗号分隔)", - type: "text", - defaultValue: "model_type", - onChange: (val) => { - window._vs_config.intercept_excludes = val; - fetch("/visual_select/config", { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({intercept_excludes: val}) - }).catch(e => console.error(e)); - } - }); - app.ui.settings.addSetting({ - id: "VisualSelect.Settings.InterceptTypes", - name: "🎨 Visual Select - 2. 拦截关键字配置 (用逗号分隔)", - type: "text", - defaultValue: "ckpt,model,lora,vae,unet,controlnet,checkpoint", - onChange: (val) => { - window._vs_config.intercept_types = val; - fetch("/visual_select/config", { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({intercept_types: val}) - }).catch(e => console.error(e)); - } - }); - app.ui.settings.addSetting({ - id: "VisualSelect.Settings.Enabled", - name: "🎨 Visual Select - 1. 开启可视化选择器", - type: "boolean", - defaultValue: true, - onChange: (val) => { - window._vs_config.enabled = val; - fetch("/visual_select/config", { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({enabled: val}) - }).catch(e => console.error(e)); - } - }); - } - + // Intercept LiteGraph ContextMenu for Combo Widgets (V1 Canvas) if (window.LiteGraph && window.LiteGraph.ContextMenu) { const origContextMenu = window.LiteGraph.ContextMenu;