Add files via upload
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -1,4 +1,4 @@
|
||||
# ComfyUI Visual Select (视图选择器) v1.0
|
||||
# ComfyUI Visual Select (视图选择器) v1.1.0
|
||||
|
||||
ComfyUI Visual Select 是一款旨在提升模型选择体验的 ComfyUI 前端扩展插件。它通过拦截原生的模型下拉列表,提供了一个直观、支持预览图和详细信息的模型可视化浏览器,帮助用户更高效地管理和选择模型。
|
||||
|
||||
@@ -13,7 +13,7 @@ https://github.com/VodooWaWa/Civitai-Download-Tool
|
||||
|
||||
## 🎨UI界面预览
|
||||
|
||||

|
||||

|
||||
## ✨ 主要特性
|
||||
|
||||
* **🎨 可视化模型浏览**:用直观的卡片网格代替了繁琐的文字下拉菜单,支持显示模型封面图。
|
||||
@@ -24,6 +24,7 @@ https://github.com/VodooWaWa/Civitai-Download-Tool
|
||||
* 支持按模型精度 (FP Format) 过滤。
|
||||
* 支持按内容安全级别 (SFW/NSFW) 过滤。
|
||||
* **📄 HTML 详情预览**:支持直接在弹窗中渲染模型同名配套的 HTML 详细信息页面,并完美修复了本地图片资源的加载问题。
|
||||
* **📝 备注功能**:支持为每个模型创建多条备注(标题/内容),支持回车换行、一键复制、拖拽排序,编辑后自动保存,并以 JSON 持久化到模型同目录。
|
||||
* **🌐 国际化支持**:内置中英双语界面,可在设置中无缝切换。
|
||||
* **⚙️ 高度可定制 (持久化配置)**:
|
||||
* 支持自定义拦截关键字。
|
||||
@@ -31,6 +32,11 @@ https://github.com/VodooWaWa/Civitai-Download-Tool
|
||||
* 配置修改后自动保存,即使在无痕模式下也不会丢失设置。
|
||||
* **🧩 独立模型浏览器节点**:提供了一个专门的 `🎨 Visual Select Model Browser` 节点,用于独立浏览和选择任何类型的模型。
|
||||
|
||||
## 🆕 更新说明
|
||||
|
||||
* **移除 backdrop-filter**:主弹窗/详情弹窗/备注弹窗遮罩层不再启用背景毛玻璃效果,以降低渲染压力、提升流畅度。
|
||||
* **新增备注功能**:在模型卡片操作区新增“备注”入口,支持新增/删除/编辑/复制/拖拽排序,并自动保存到模型同目录的 `*.notes.json`。
|
||||
|
||||
## 📦 安装说明
|
||||
|
||||
1. 进入你的 ComfyUI 插件目录 (`ComfyUI/custom_nodes/`)。
|
||||
@@ -51,14 +57,21 @@ https://github.com/VodooWaWa/Civitai-Download-Tool
|
||||
3. 然后点击 `selected_model`,即可弹出对应的可视化界面进行挑选。
|
||||
4. 将该节点的输出连接到任何接受模型路径字符串的节点即可使用。
|
||||
|
||||
### 📝 备注功能
|
||||
在模型卡片上点击“备注”按钮即可打开备注弹窗。支持多条条目、标题与内容编辑(支持回车换行)、一键复制内容、拖拽排序,并在编辑框失焦后自动保存到模型同目录的 `*.notes.json` 文件中。
|
||||
|
||||

|
||||
|
||||
## ⚙️ 设置选项
|
||||
|
||||
点击 ComfyUI 界面右上角的齿轮图标进入**设置 (Settings)**,在列表中可以找到以下配置项:
|
||||
|
||||
* **🎨 Visual Select - 1. 开启可视化选择器**: 插件总开关,开启后生效。
|
||||
* **🎨 Visual Select - 2. 拦截关键字配置**: 只有当节点的属性名包含这些关键字时(如 ckpt, lora 等),才会弹出悬浮按钮。使用英文逗号分隔。
|
||||
* **🎨 Visual Select - 3. 排除关键字配置**: 当节点属性名包含这些关键字时,强制**不拦截**(直接显示原生下拉框)。默认包含 `model_type`。
|
||||
* **🎨 Visual Select - 4. 语言 (Language)**: 切换可视化界面的语言(中文 / English)。
|
||||
以下选项位于设置分组 `🎨Visual Select` 下:
|
||||
|
||||
* **开启视图选择器**: 插件总开关,开启后生效。
|
||||
* **监听关键字(逗号分隔)**: 只有当节点的属性名包含这些关键字时(如 ckpt, lora 等),才会弹出悬浮按钮。使用英文逗号分隔。
|
||||
* **排除关键字(逗号分隔)**: 当节点属性名包含这些关键字时,强制**不拦截**(直接显示原生下拉框)。默认包含 `model_type`。
|
||||
* **语言 (Language)**: 切换可视化界面的语言(中文 / English)。
|
||||
|
||||
> **提示**:所有的设置都会自动保存在插件目录下的 `config.json` 文件中,支持持久化。
|
||||
|
||||
|
||||
+30
-7
@@ -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
|
||||

|
||||

|
||||
|
||||
## ✨ 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.
|
||||
|
||||

|
||||
|
||||
## ⚙️ 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).
|
||||
* Compatible with ComfyUI's latest Vue component architecture (Node 2.0 Canvas).
|
||||
|
||||
+203
-130
@@ -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):
|
||||
|
||||
+293
-36
@@ -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;
|
||||
}
|
||||
|
||||
+405
-68
@@ -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 = `
|
||||
<div class="vs-overlay"></div>
|
||||
<div class="vs-modal vs-modal-large">
|
||||
<div class="vs-header">
|
||||
<h2>📝 ${t('notesTitle')}: ${_escapeHtml(displayName || modelPath.split('/').pop())}</h2>
|
||||
<div class="vs-close">×</div>
|
||||
</div>
|
||||
<div class="vs-notes-toolbar">
|
||||
<button class="vs-details-btn vs-notes-add">${t('add')}</button>
|
||||
<button class="vs-note-btn vs-notes-del" disabled>${t('del')}</button>
|
||||
</div>
|
||||
<div class="vs-notes-body">
|
||||
<div class="vs-notes-top">
|
||||
<div class="vs-notes-list"></div>
|
||||
<div class="vs-notes-editor">
|
||||
<input class="vs-note-title-input" type="text" />
|
||||
<div class="vs-note-content-wrap">
|
||||
<textarea class="vs-note-content-input"></textarea>
|
||||
<button class="vs-note-copy-btn" title="${t('copy')}" type="button" aria-label="${t('copy')}">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm4 4H8c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 18H8V7h12v16z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vs-notes-preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `<img src="/visual_select/preview?name=${encodedName}&format=image&type=${window._vs_current_model_type || ''}" alt="${m.name}" loading="lazy" />`;
|
||||
}
|
||||
|
||||
let detailsBtn = '<div class="vs-details-placeholder" style="height: 28px; width: 100%;"></div>';
|
||||
let detailsBtn = "";
|
||||
if (m.info.has_html) {
|
||||
const encodedName = encodeURIComponent(m.fullPath);
|
||||
// Changed from <a> tag to <button> to ensure consistent event handling
|
||||
detailsBtn = `<button class="vs-details-btn" data-url="/visual_select/preview?name=${encodedName}&format=html&type=${window._vs_current_model_type || ''}">${t('details')}</button>`;
|
||||
}
|
||||
|
||||
@@ -345,6 +657,13 @@ function buildUI(dialog, widget, node, models, info) {
|
||||
}
|
||||
badgesHtml += '</div>';
|
||||
|
||||
const actionRowHtml = `
|
||||
<div class="vs-card-actions-row">
|
||||
<button class="vs-note-btn" data-name="${_escapeHtml(m.fullPath)}" data-display="${_escapeHtml(displayName)}">${t('note')}</button>
|
||||
${detailsBtn}
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="vs-card-img${(blurNsfw && m.info.is_nsfw) ? ' vs-nsfw-blur' : ''}">${imgHtml}</div>
|
||||
<div class="vs-card-info-wrapper">
|
||||
@@ -356,7 +675,7 @@ function buildUI(dialog, widget, node, models, info) {
|
||||
<button class="vs-select-btn" data-name="${m.fullPath}">
|
||||
${t('select')}
|
||||
</button>
|
||||
${detailsBtn}
|
||||
${actionRowHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user