Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ab9e1946 | ||
|
|
2db8592267 | ||
|
|
9cb8f7faba | ||
|
|
a95d2ca1b7 | ||
|
|
f5f5016346 | ||
|
|
e9c7205e25 | ||
|
|
6b06b18bca | ||
|
|
1c6886712a | ||
|
|
2286083b9b | ||
|
|
a23ba4fae2 | ||
|
|
09677772c6 | ||
|
|
37522e484a | ||
|
|
270616a744 | ||
|
|
2941e48879 | ||
|
|
f7436011c4 | ||
|
|
e783422e7c | ||
|
|
41d2e38f2f | ||
|
|
56247cfa8b | ||
|
|
328915a7ee | ||
|
|
543e9e999a | ||
|
|
421361edb8 | ||
|
|
647c95a5df | ||
|
|
7421dfa756 | ||
|
|
60883fdf63 | ||
|
|
b6143a3abf | ||
|
|
828d510891 | ||
|
|
024309c050 | ||
|
|
2e09cf3963 | ||
|
|
ef424be4b1 | ||
|
|
a1fa872e2e | ||
|
|
d31852e5ab | ||
|
|
bb98171982 | ||
|
|
004744e8e2 | ||
|
|
94933a0246 | ||
|
|
c942976c02 | ||
|
|
0d19394c8f | ||
|
|
4bca1435a6 | ||
|
|
4eb67f25f0 | ||
|
|
40a9cf3db9 | ||
|
|
ff0466957e | ||
|
|
294e610eb1 | ||
|
|
a33052141a | ||
|
|
e2a3c0c596 | ||
|
|
557d910ab4 | ||
|
|
be5c8c41c9 | ||
|
|
408517273a | ||
|
|
e8b6622919 | ||
|
|
4001848e37 | ||
|
|
0733d001f3 | ||
|
|
33878502af | ||
|
|
2c9ded2cbf |
@@ -18,7 +18,9 @@ A compact and powerful layout overhaul for ComfyUI that makes generation results
|
||||
|
||||
<img src="assets/compare.png" alt="ComfySidebar compare" width="100%">
|
||||
|
||||
* **[NEW] Output Gallery sidebar:** Browse files and subfolders directly from your ComfyUI `output` directory without leaving the interface. Features dedicated cards for images, videos, audio, and 3D assets (`.glb`/`.gltf`), breadcrumb navigation, prompt metadata search across subfolders, and persistent sorting.
|
||||
* **[NEW] Output Gallery sidebar:** Browse files and subfolders directly from your ComfyUI `output` directory without leaving the interface. Features dedicated cards for images, videos, audio, and 3D assets (`.glb`/`.gltf`), breadcrumb navigation, filename and prompt metadata search across subfolders, and persistent sorting.
|
||||
|
||||
* **Hotkeys on any keyboard layout:** Shortcuts such as `Q`, `Ctrl+Q` and `Alt+R` keep working while a non-Latin layout (for example Russian) is active. Non-Latin keys are treated as the Latin key in the same physical position, and typing in text fields is not affected. Can be turned off with **Fix non-ASCII keybinds** in the settings.
|
||||
|
||||
* **Runtime preview mode** combined with workflow overview makes ComfyUI practical for running and monitoring workflows in a more streamlined, queue-oriented way.
|
||||
|
||||
@@ -61,6 +63,7 @@ Open **Settings → Comfy Sidebar** to configure:
|
||||
* Unified top bar layout
|
||||
* Permanent deletion behavior
|
||||
* Output Gallery sidebar toggle
|
||||
* Fix non-ASCII keybinds (hotkeys on any keyboard layout)
|
||||
|
||||
## ⌨️ Controls
|
||||
|
||||
@@ -73,12 +76,16 @@ Open **Settings → Comfy Sidebar** to configure:
|
||||
| `Shift+Click` | Compare another result |
|
||||
| `Ctrl+Click` | Open the file location / alternative action |
|
||||
|
||||
`Q` is a regular ComfyUI keybinding, so you can see and change it in **Settings → Keybinding**.
|
||||
|
||||
You can also use the available actions from result cards, the sidebar header, and the node/canvas context menus.
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
ComfySidebar integrates with the ComfyUI frontend and depends on parts of its UI structure. Frontend changes in ComfyUI may require updates to this extension.
|
||||
|
||||
**Keyboard layouts:** the *Fix non-ASCII keybinds* option only re-maps keys that produce a non-ASCII character, so QWERTY, AZERTY and Dvorak users are not affected. It cannot help with layouts that type a *different ASCII* symbol on a key (for example, `+` where a US layout has `=`). ComfyUI's "edit keybinding" dialog still records the typed character, so switch to an English layout while rebinding a key.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
+164
-124
@@ -1,11 +1,14 @@
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import json
|
||||
import threading
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
|
||||
from . import prompt_search
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
WEB_DIRECTORY = "./js"
|
||||
@@ -61,10 +64,35 @@ def get_all_candidate_roots(subfolder="", folder_type="output"):
|
||||
return unique
|
||||
|
||||
|
||||
def resolve_existing_path(filename="", subfolder="", folder_type="output", candidate_roots=None):
|
||||
"""Finds the actual location of a file or folder across all candidate root directories."""
|
||||
def resolve_existing_path(filename="", subfolder="", folder_type="output", candidate_roots=None, strict=False):
|
||||
"""Finds the actual location of a file or folder across all candidate root directories.
|
||||
|
||||
strict=True is for destructive operations: the file must exist at exactly
|
||||
<root>/<subfolder>/<filename> (no fallback to a same-named file in the root),
|
||||
and only the root matching `folder_type` (output/input/temp) is considered.
|
||||
"""
|
||||
candidates = candidate_roots if candidate_roots is not None else get_all_candidate_roots(subfolder, folder_type)
|
||||
|
||||
if strict:
|
||||
type_dirs = {
|
||||
"output": folder_paths.get_output_directory(),
|
||||
"input": folder_paths.get_input_directory(),
|
||||
"temp": folder_paths.get_temp_directory(),
|
||||
}
|
||||
wanted = type_dirs.get(folder_type)
|
||||
if wanted is None:
|
||||
return None, None
|
||||
wanted = os.path.realpath(wanted)
|
||||
if not filename:
|
||||
return None, None
|
||||
for root in candidates:
|
||||
if os.path.realpath(root) != wanted:
|
||||
continue
|
||||
p = os.path.join(root, subfolder, filename) if subfolder else os.path.join(root, filename)
|
||||
if os.path.isfile(p) and is_path_safe(root, p):
|
||||
return p, root
|
||||
return None, None
|
||||
|
||||
if filename:
|
||||
for root in candidates:
|
||||
if subfolder:
|
||||
@@ -118,41 +146,104 @@ def open_file_in_os(target_path):
|
||||
raise RuntimeError("Headless Linux server detected (no desktop GUI available).")
|
||||
|
||||
|
||||
def check_png_prompt_match(filepath, search_term):
|
||||
"""Checks PNG prompt metadata strictly for text inputs matching search."""
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
if f.read(8) != b"\x89PNG\r\n\x1a\n":
|
||||
return False
|
||||
while True:
|
||||
len_b = f.read(4)
|
||||
if len(len_b) < 4:
|
||||
break
|
||||
length = int.from_bytes(len_b, "big")
|
||||
ctype = f.read(4)
|
||||
if ctype in (b"tEXt", b"iTXt"):
|
||||
data = f.read(length)
|
||||
if data.startswith(b"prompt\x00") or b'"inputs"' in data:
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
idx = text.find("{")
|
||||
if idx != -1:
|
||||
try:
|
||||
prompt_dict = json.loads(text[idx:])
|
||||
for node in prompt_dict.values():
|
||||
if isinstance(node, dict) and "inputs" in node:
|
||||
for val in node["inputs"].values():
|
||||
if isinstance(val, str) and search_term in val.lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
elif ctype in (b"IDAT", b"IEND"):
|
||||
break
|
||||
else:
|
||||
f.seek(length, 1)
|
||||
f.seek(4, 1)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
_GALLERY_EXTS = {
|
||||
".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif",
|
||||
".mp4", ".webm", ".mov", ".mkv",
|
||||
".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac",
|
||||
".glb", ".gltf", ".obj", ".fbx", ".stl",
|
||||
".json"
|
||||
}
|
||||
|
||||
|
||||
def _relative_subfolder(directory, output_dir):
|
||||
rel_sub = os.path.relpath(directory, output_dir).replace("\\", "/")
|
||||
return "" if rel_sub == "." else rel_sub
|
||||
|
||||
|
||||
def _list_gallery(subfolder, search, cancel):
|
||||
"""Blocking directory listing / prompt search. Runs in a worker thread.
|
||||
|
||||
Returns (http_status, json_payload).
|
||||
"""
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
target_dir = os.path.realpath(os.path.join(output_dir, subfolder)) if subfolder else os.path.realpath(output_dir)
|
||||
|
||||
if not is_path_safe(output_dir, target_dir):
|
||||
return 403, {"error": "Unsafe path"}
|
||||
|
||||
if not os.path.exists(target_dir) or not os.path.isdir(target_dir):
|
||||
return 200, {"subfolder": subfolder, "folders": [], "files": []}
|
||||
|
||||
folders = []
|
||||
files = []
|
||||
|
||||
if search:
|
||||
# Recursive filename + prompt-text search. Prompt metadata is only read for PNGs.
|
||||
for root, dirs, fnames in os.walk(target_dir):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for fname in fnames:
|
||||
if cancel.is_set():
|
||||
return 200, {
|
||||
"subfolder": subfolder.replace("\\", "/"),
|
||||
"folders": folders,
|
||||
"files": files
|
||||
}
|
||||
if fname.startswith("."):
|
||||
continue
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext not in _GALLERY_EXTS:
|
||||
continue
|
||||
|
||||
fpath = os.path.join(root, fname)
|
||||
matched = search in fname.lower()
|
||||
if not matched and ext == ".png":
|
||||
matched = search in prompt_search.get_prompt_search_text(fpath)
|
||||
if matched:
|
||||
try:
|
||||
stat = os.stat(fpath)
|
||||
except (PermissionError, FileNotFoundError):
|
||||
continue
|
||||
files.append({
|
||||
"filename": fname,
|
||||
"subfolder": _relative_subfolder(root, output_dir),
|
||||
"type": "output",
|
||||
"mtime": stat.st_mtime,
|
||||
"size": stat.st_size
|
||||
})
|
||||
else:
|
||||
# Instant single-directory listing without reading files from disk
|
||||
with os.scandir(target_dir) as entries:
|
||||
for entry in entries:
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
folders.append({
|
||||
"name": entry.name,
|
||||
"subfolder": os.path.relpath(entry.path, output_dir).replace("\\", "/"),
|
||||
"mtime": entry.stat().st_mtime
|
||||
})
|
||||
elif entry.is_file(follow_symlinks=False):
|
||||
if os.path.splitext(entry.name)[1].lower() in _GALLERY_EXTS:
|
||||
stat = entry.stat()
|
||||
files.append({
|
||||
"filename": entry.name,
|
||||
"subfolder": _relative_subfolder(os.path.dirname(entry.path), output_dir),
|
||||
"type": "output",
|
||||
"mtime": stat.st_mtime,
|
||||
"size": stat.st_size
|
||||
})
|
||||
except (PermissionError, FileNotFoundError):
|
||||
continue
|
||||
|
||||
folders.sort(key=lambda x: x["name"].lower())
|
||||
files.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
|
||||
return 200, {
|
||||
"subfolder": subfolder.replace("\\", "/"),
|
||||
"folders": folders,
|
||||
"files": files
|
||||
}
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/comfy-sidebar/gallery/list")
|
||||
@@ -160,96 +251,45 @@ async def gallery_list_handler(request):
|
||||
try:
|
||||
subfolder = request.query.get("subfolder", "").strip().strip("/\\")
|
||||
search = request.query.get("search", "").strip().lower()
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
|
||||
target_dir = os.path.realpath(os.path.join(output_dir, subfolder)) if subfolder else os.path.realpath(output_dir)
|
||||
|
||||
if not is_path_safe(output_dir, target_dir):
|
||||
return web.json_response({"error": "Unsafe path"}, status=403)
|
||||
|
||||
if not os.path.exists(target_dir) or not os.path.isdir(target_dir):
|
||||
return web.json_response({"subfolder": subfolder, "folders": [], "files": []})
|
||||
|
||||
folders = []
|
||||
files = []
|
||||
|
||||
supported_exts = {
|
||||
".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif",
|
||||
".mp4", ".webm", ".mov", ".mkv",
|
||||
".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac",
|
||||
".glb", ".gltf", ".obj", ".fbx", ".stl",
|
||||
".json"
|
||||
}
|
||||
|
||||
if search:
|
||||
# Recursive search in current directory and all subfolders
|
||||
for root, dirs, fnames in os.walk(target_dir):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for fname in fnames:
|
||||
if fname.startswith("."):
|
||||
continue
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext in supported_exts:
|
||||
fpath = os.path.join(root, fname)
|
||||
matched = search in fname.lower()
|
||||
if not matched and ext == ".png":
|
||||
matched = check_png_prompt_match(fpath, search)
|
||||
|
||||
if matched:
|
||||
rel_sub = os.path.relpath(root, output_dir).replace("\\", "/")
|
||||
if rel_sub == ".":
|
||||
rel_sub = ""
|
||||
stat = os.stat(fpath)
|
||||
files.append({
|
||||
"filename": fname,
|
||||
"subfolder": rel_sub,
|
||||
"type": "output",
|
||||
"mtime": stat.st_mtime,
|
||||
"size": stat.st_size
|
||||
})
|
||||
else:
|
||||
# Instant single-directory listing without reading files from disk
|
||||
with os.scandir(target_dir) as entries:
|
||||
for entry in entries:
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
rel_sub = os.path.relpath(entry.path, output_dir).replace("\\", "/")
|
||||
folders.append({
|
||||
"name": entry.name,
|
||||
"subfolder": rel_sub,
|
||||
"mtime": entry.stat().st_mtime
|
||||
})
|
||||
elif entry.is_file(follow_symlinks=False):
|
||||
ext = os.path.splitext(entry.name)[1].lower()
|
||||
if ext in supported_exts:
|
||||
rel_sub = os.path.relpath(os.path.dirname(entry.path), output_dir).replace("\\", "/")
|
||||
if rel_sub == ".":
|
||||
rel_sub = ""
|
||||
stat = entry.stat()
|
||||
files.append({
|
||||
"filename": entry.name,
|
||||
"subfolder": rel_sub,
|
||||
"type": "output",
|
||||
"mtime": stat.st_mtime,
|
||||
"size": stat.st_size
|
||||
})
|
||||
except (PermissionError, FileNotFoundError):
|
||||
continue
|
||||
|
||||
folders.sort(key=lambda x: x["name"].lower())
|
||||
files.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
|
||||
return web.json_response({
|
||||
"subfolder": subfolder.replace("\\", "/"),
|
||||
"folders": folders,
|
||||
"files": files
|
||||
})
|
||||
# Scanning can touch thousands of files: keep it off the event loop so
|
||||
# ComfyUI's server (websocket, queue updates) stays responsive.
|
||||
cancel = threading.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
status, payload = await loop.run_in_executor(None, _list_gallery, subfolder, search, cancel)
|
||||
except asyncio.CancelledError:
|
||||
cancel.set() # client went away / sent a newer search: stop scanning
|
||||
raise
|
||||
return web.json_response(payload, status=status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/comfy-sidebar/queue/prompt-search")
|
||||
async def queue_prompt_search_handler(request):
|
||||
"""Extract searchable prompt text from Queue API-format prompt graphs."""
|
||||
try:
|
||||
data = await request.json()
|
||||
prompts = data.get("prompts", [])
|
||||
if not isinstance(prompts, list) or len(prompts) > 32:
|
||||
return web.json_response({"error": "Expected up to 32 prompt graphs"}, status=400)
|
||||
|
||||
texts = {}
|
||||
for item in prompts:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
pid = item.get("pid")
|
||||
graph = item.get("prompt")
|
||||
if pid is None or not isinstance(graph, dict):
|
||||
continue
|
||||
texts[str(pid)] = prompt_search.get_prompt_search_text_from_graph(graph)
|
||||
|
||||
return web.json_response({"texts": texts})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/comfy-sidebar/open-folder")
|
||||
async def open_folder_handler(request):
|
||||
try:
|
||||
@@ -286,7 +326,7 @@ async def delete_file_handler(request):
|
||||
]
|
||||
delete_roots = [os.path.realpath(r) for r in delete_roots if r and os.path.exists(r)]
|
||||
|
||||
target_file, root = resolve_existing_path(filename, subfolder, folder_type, candidate_roots=delete_roots)
|
||||
target_file, root = resolve_existing_path(filename, subfolder, folder_type, candidate_roots=delete_roots, strict=True)
|
||||
|
||||
if not target_file or not os.path.isfile(target_file) or not is_path_safe(root, target_file):
|
||||
return web.json_response({"error": "File not found or outside output directory"}, status=404)
|
||||
|
||||
@@ -25,6 +25,7 @@ export const SettingIds = Object.freeze({
|
||||
OVERRIDE_STOCK_HISTORY: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
|
||||
HIDE_GRAPH_BUTTON: "Comfy Sidebar.Hide Junk.Graph Button",
|
||||
CLASSIC_LAYOUT: "Comfy Sidebar.Comfy Layout",
|
||||
LATIN_HOTKEYS: "Comfy Sidebar.Latin Hotkeys On Any Layout",
|
||||
MAX_HISTORY_ITEMS: "Comfy.Queue.MaxHistoryItems",
|
||||
DOCK_QUEUE: "Comfy.Queue.QPOV2",
|
||||
SHOW_PROGRESS_BAR: "Comfy.Queue.ShowRunProgressBar",
|
||||
|
||||
@@ -32,6 +32,7 @@ export const PersistenceAdapter = {
|
||||
status: state.status,
|
||||
images: cleanedImages,
|
||||
texts: state.texts || [],
|
||||
promptSearchText: state.promptSearchText,
|
||||
nodeOutputs: state.nodeOutputs || {},
|
||||
nodeTitles: state.nodeTitles || {}, // Always preserved across restarts
|
||||
workflow: includeWorkflow ? state.workflow : null,
|
||||
|
||||
@@ -6,6 +6,7 @@ export function createPromptState(pid, initial = {}) {
|
||||
status: initial.status || PromptStatus.PENDING,
|
||||
images: Array.isArray(initial.images) ? [...initial.images] : [],
|
||||
texts: Array.isArray(initial.texts) ? [...initial.texts] : [],
|
||||
promptSearchText: typeof initial.promptSearchText === "string" ? initial.promptSearchText : null,
|
||||
nodeOutputs: initial.nodeOutputs ? { ...initial.nodeOutputs } : {},
|
||||
nodeTitles: initial.nodeTitles ? { ...initial.nodeTitles } : {},
|
||||
progress: typeof initial.progress === "number" ? initial.progress : 0,
|
||||
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "افتراضي",
|
||||
"Dock": "إرساء",
|
||||
"Hide": "إخفاء",
|
||||
"Enable Output Gallery Sidebar": "تمكين الشريط الجانبي لمعرض المخرجات"
|
||||
"Enable Output Gallery Sidebar": "تمكين الشريط الجانبي لمعرض المخرجات",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "إصلاح اختصارات المفاتيح غير ASCII (اختصارات تعمل على أي تخطيط للوحة المفاتيح)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "يعامل الأحرف غير اللاتينية (مثل السيريلية) على أنها المفتاح اللاتيني في الموضع الفعلي نفسه، فتعمل الاختصارات مثل Q أو Ctrl+Z عند تفعيل تخطيط آخر. ولا تتأثر الكتابة في حقول النص."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "Default",
|
||||
"Dock": "Dock",
|
||||
"Hide": "Hide",
|
||||
"Enable Output Gallery Sidebar": "Enable Output Gallery Sidebar"
|
||||
"Enable Output Gallery Sidebar": "Enable Output Gallery Sidebar",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Fix non-ASCII keybinds (hotkeys on any keyboard layout)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "Por defecto",
|
||||
"Dock": "Acoplar",
|
||||
"Hide": "Ocultar",
|
||||
"Enable Output Gallery Sidebar": "Habilitar barra lateral de galería de salidas"
|
||||
"Enable Output Gallery Sidebar": "Habilitar barra lateral de galería de salidas",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Corregir atajos con caracteres no ASCII (atajos en cualquier distribución de teclado)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Trata las letras no latinas (p. ej., cirílico) como la tecla latina en la misma posición física, de modo que atajos como Q o Ctrl+Z funcionen con otra distribución activa. No afecta a la escritura en campos de texto."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "پیشفرض",
|
||||
"Dock": "اتصال",
|
||||
"Hide": "پنهان",
|
||||
"Enable Output Gallery Sidebar": "فعالسازی نوار کناری گالری خروجیها"
|
||||
"Enable Output Gallery Sidebar": "فعالسازی نوار کناری گالری خروجیها",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "رفع مشکل کلیدهای میانبر غیر ASCII (میانبرها در هر چیدمان صفحهکلید)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "حروف غیر لاتین (مثلاً سیریلیک) را معادل کلید لاتین در همان موقعیت فیزیکی در نظر میگیرد تا میانبرهایی مانند Q یا Ctrl+Z هنگام فعال بودن چیدمان دیگر نیز کار کنند. تایپ در فیلدهای متنی تحت تأثیر قرار نمیگیرد."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "Par défaut",
|
||||
"Dock": "Ancrer",
|
||||
"Hide": "Masquer",
|
||||
"Enable Output Gallery Sidebar": "Activer la barre latérale de la galerie de sorties"
|
||||
"Enable Output Gallery Sidebar": "Activer la barre latérale de la galerie de sorties",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Corriger les raccourcis non ASCII (raccourcis sur toutes les dispositions de clavier)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Traite les lettres non latines (p. ex. cyrillique) comme la touche latine située au même emplacement physique, afin que des raccourcis comme Q ou Ctrl+Z fonctionnent avec une autre disposition active. La saisie dans les champs de texte n'est pas affectée."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "ברירת מחדל",
|
||||
"Dock": "הצמד",
|
||||
"Hide": "הסתר",
|
||||
"Enable Output Gallery Sidebar": "הפעל סרגל צד של גלריית תוצרים"
|
||||
"Enable Output Gallery Sidebar": "הפעל סרגל צד של גלריית תוצרים",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "תיקון קיצורי מקשים שאינם ASCII (קיצורים בכל פריסת מקלדת)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "מתייחס לאותיות שאינן לטיניות (למשל קירילית) כמקש הלטיני באותו מיקום פיזי, כך שקיצורים כמו Q או Ctrl+Z פועלים גם כשפריסה אחרת פעילה. ההקלדה בשדות טקסט אינה מושפעת."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "Predefinito",
|
||||
"Dock": "Aggancia",
|
||||
"Hide": "Nascondi",
|
||||
"Enable Output Gallery Sidebar": "Abilita barra laterale galleria di output"
|
||||
"Enable Output Gallery Sidebar": "Abilita barra laterale galleria di output",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Correggi le scorciatoie non ASCII (scorciatoie con qualsiasi layout di tastiera)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Considera le lettere non latine (ad es. cirillico) come il tasto latino nella stessa posizione fisica, così scorciatoie come Q o Ctrl+Z funzionano anche con un altro layout attivo. La digitazione nei campi di testo non è interessata."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "デフォルト",
|
||||
"Dock": "ドック",
|
||||
"Hide": "非表示",
|
||||
"Enable Output Gallery Sidebar": "出力ギャラリーのサイドバーを有効化"
|
||||
"Enable Output Gallery Sidebar": "出力ギャラリーのサイドバーを有効化",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "非ASCIIのキーバインドを修正(どのキーボード配列でもショートカットを使用)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "ラテン文字以外の文字(例:キリル文字)を同じ物理位置のラテン文字キーとして扱い、別の配列が有効な状態でも Q や Ctrl+Z などのショートカットが動作するようにします。テキスト入力欄での入力には影響しません。"
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "기본값",
|
||||
"Dock": "도킹",
|
||||
"Hide": "숨기기",
|
||||
"Enable Output Gallery Sidebar": "출력 갤러리 사이드바 활성화"
|
||||
"Enable Output Gallery Sidebar": "출력 갤러리 사이드바 활성화",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "비ASCII 단축키 수정(모든 키보드 배열에서 단축키 사용)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "라틴 문자가 아닌 글자(예: 키릴 문자)를 같은 물리적 위치의 라틴 키로 처리하여, 다른 자판 배열이 활성화되어 있어도 Q, Ctrl+Z 같은 단축키가 작동합니다. 텍스트 입력란에서의 입력에는 영향을 주지 않습니다."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "Padrão",
|
||||
"Dock": "Fixar",
|
||||
"Hide": "Ocultar",
|
||||
"Enable Output Gallery Sidebar": "Ativar barra lateral da galeria de saídas"
|
||||
"Enable Output Gallery Sidebar": "Ativar barra lateral da galeria de saídas",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Corrigir atalhos não ASCII (atalhos em qualquer layout de teclado)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Trata letras não latinas (por exemplo, cirílico) como a tecla latina na mesma posição física, para que atalhos como Q ou Ctrl+Z funcionem com outro layout ativo. A digitação em campos de texto não é afetada."
|
||||
}
|
||||
@@ -22,7 +22,6 @@
|
||||
"Hide floating 'Graph' (Workflow/Node Map) button": "Скрыть плавающую кнопку 'Граф' (карта нод)",
|
||||
"Hide Avatar": "Скрыть аватар",
|
||||
"Floating Canvas Controls": "Плавающие элементы управления",
|
||||
"Classic Layout": "Классический интерфейс",
|
||||
"Places the controls and the open workflow tabs on a single unified top bar": "Разместить элементы управления и открытые вкладки на единой верхней панели",
|
||||
"Comfy Layout": "Компактный интерфейс",
|
||||
"Classic Layout": "Компактный интерфейс",
|
||||
@@ -30,5 +29,7 @@
|
||||
"Default": "По умолчанию",
|
||||
"Dock": "Закрепить",
|
||||
"Hide": "Скрыть",
|
||||
"Enable Output Gallery Sidebar": "Включить боковую панель галереи результатов"
|
||||
"Enable Output Gallery Sidebar": "Включить боковую панель галереи результатов",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Исправить не-ASCII горячие клавиши (работа в любой раскладке)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Нелатинские буквы (например, кириллица) считаются латинской клавишей в том же физическом положении, поэтому горячие клавиши вроде Q или Ctrl+Z работают при любой активной раскладке. Ввод текста в полях не затрагивается."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "Varsayılan",
|
||||
"Dock": "Sabitle",
|
||||
"Hide": "Gizle",
|
||||
"Enable Output Gallery Sidebar": "Çıktı Galerisi Kenar Çubuğunu Etkinleştir"
|
||||
"Enable Output Gallery Sidebar": "Çıktı Galerisi Kenar Çubuğunu Etkinleştir",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "ASCII olmayan kısayolları düzelt (her klavye düzeninde kısayollar)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Latin olmayan harfleri (örn. Kiril) aynı fiziksel konumdaki Latin tuş olarak kabul eder; böylece başka bir düzen etkinken Q veya Ctrl+Z gibi kısayollar çalışır. Metin alanlarına yazma etkilenmez."
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "預設",
|
||||
"Dock": "停靠",
|
||||
"Hide": "隱藏",
|
||||
"Enable Output Gallery Sidebar": "啟用輸出圖庫側邊欄"
|
||||
"Enable Output Gallery Sidebar": "啟用輸出圖庫側邊欄",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "修正非 ASCII 快速鍵(任何鍵盤配置下皆可使用)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "將非拉丁字母(如西里爾字母)視為相同實體位置的拉丁按鍵,因此在使用其他鍵盤配置時,Q 或 Ctrl+Z 等快速鍵也能正常運作。在文字欄位中輸入不受影響。"
|
||||
}
|
||||
@@ -29,5 +29,7 @@
|
||||
"Default": "默认",
|
||||
"Dock": "停靠",
|
||||
"Hide": "隐藏",
|
||||
"Enable Output Gallery Sidebar": "启用输出图库侧边栏"
|
||||
"Enable Output Gallery Sidebar": "启用输出图库侧边栏",
|
||||
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "修复非 ASCII 快捷键(任意键盘布局下可用)",
|
||||
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "将非拉丁字母(如西里尔字母)视为同一物理位置的拉丁按键,这样在启用其他键盘布局时,Q 或 Ctrl+Z 等快捷键也能正常使用。在文本框中输入不受影响。"
|
||||
}
|
||||
@@ -25,7 +25,8 @@ export function normalizeQueueItem(p, defaultIndex = 0) {
|
||||
pid,
|
||||
seq,
|
||||
workflow: parseWorkflow(rawWorkflow),
|
||||
outputs
|
||||
outputs,
|
||||
promptGraph: Array.isArray(p) ? (p[2] || null) : (p.prompt || null)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,46 @@ export function extractPromptOutputs(outputs, workflow, promptGraph = null) {
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePromptSearchTexts(entries) {
|
||||
const pending = entries.filter(({ pid, promptGraph }) => {
|
||||
const state = store.getPrompt(pid);
|
||||
return promptGraph && typeof promptGraph === "object" && state && state.promptSearchText === null;
|
||||
});
|
||||
if (pending.length === 0) return;
|
||||
|
||||
// Keep each request comfortably below aiohttp's default 1 MB body limit.
|
||||
const batches = [];
|
||||
let batch = [];
|
||||
let batchSize = 0;
|
||||
for (const entry of pending) {
|
||||
const itemSize = JSON.stringify(entry).length * 3; // Conservative UTF-8 byte estimate
|
||||
if (batch.length > 0 && (batch.length >= 32 || batchSize + itemSize > 512 * 1024)) {
|
||||
batches.push(batch);
|
||||
batch = [];
|
||||
batchSize = 0;
|
||||
}
|
||||
batch.push(entry);
|
||||
batchSize += itemSize;
|
||||
}
|
||||
if (batch.length > 0) batches.push(batch);
|
||||
|
||||
for (const prompts of batches) {
|
||||
try {
|
||||
const response = await api.fetchApi("/comfy-sidebar/queue/prompt-search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ prompts: prompts.map(({ pid, promptGraph }) => ({ pid, prompt: promptGraph })) })
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
for (const [pid, text] of Object.entries(data.texts || {})) {
|
||||
if (store.hasPrompt(pid)) store.updatePrompt(pid, { promptSearchText: String(text || "") });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Comfy Sidebar: Failed to extract Queue prompt search text", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncQueue() {
|
||||
try {
|
||||
const q = await api.getQueue();
|
||||
@@ -35,12 +75,14 @@ export async function syncQueue() {
|
||||
|
||||
const runningIds = new Set();
|
||||
const pendingIds = new Set();
|
||||
const promptGraphs = [];
|
||||
|
||||
runningList.forEach((raw, idx) => {
|
||||
const item = normalizeQueueItem(raw, idx);
|
||||
if (!item.pid) return;
|
||||
|
||||
runningIds.add(item.pid);
|
||||
if (item.promptGraph) promptGraphs.push({ pid: item.pid, promptGraph: item.promptGraph });
|
||||
store.setActivePromptId(item.pid);
|
||||
|
||||
const existing = store.getPrompt(item.pid);
|
||||
@@ -84,6 +126,7 @@ export async function syncQueue() {
|
||||
normalizedPending.forEach((item, index) => {
|
||||
const number = index + 1;
|
||||
pendingIds.add(item.pid);
|
||||
if (item.promptGraph) promptGraphs.push({ pid: item.pid, promptGraph: item.promptGraph });
|
||||
|
||||
const existing = store.getPrompt(item.pid);
|
||||
if (!existing) {
|
||||
@@ -108,6 +151,8 @@ export async function syncQueue() {
|
||||
}
|
||||
});
|
||||
|
||||
await updatePromptSearchTexts(promptGraphs);
|
||||
|
||||
for (const prompt of store.getAllPrompts()) {
|
||||
if (prompt.status === PromptStatus.PENDING && !pendingIds.has(prompt.pid)) {
|
||||
store.deletePrompt(prompt.pid);
|
||||
@@ -153,6 +198,7 @@ export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED)
|
||||
state.status = norm.status;
|
||||
|
||||
const promptGraph = hItem[key].prompt?.[2] || null;
|
||||
await updatePromptSearchTexts([{ pid: key, promptGraph }]);
|
||||
const { images: primaryImgs, texts: fetchedTexts, nodeTitles: titles } = extractPromptOutputs(norm.outputs, state.workflow, promptGraph);
|
||||
Object.assign(state.nodeTitles, titles);
|
||||
|
||||
@@ -257,6 +303,8 @@ export async function initSessionAndHistory() {
|
||||
store.setPrompt(item.pid, newState);
|
||||
});
|
||||
|
||||
await updatePromptSearchTexts(normalized.map(({ norm, promptGraph }) => ({ pid: norm.pid, promptGraph })));
|
||||
|
||||
const maxItems = getSetting(SettingIds.MAX_HISTORY_ITEMS, 64);
|
||||
store.pruneHistory(maxItems);
|
||||
await syncQueue();
|
||||
|
||||
+19
-13
@@ -2,8 +2,9 @@ import { app } from "/scripts/app.js";
|
||||
import { SettingIds } from "./core/constants.js";
|
||||
import { injectStyles } from "./utils/styles.js";
|
||||
import { setupDragAndDrop } from "./utils/dragdrop.js";
|
||||
import { setupLatinHotkeys } from "./utils/latinHotkeys.js";
|
||||
import { renumberNodesTopologically, setManualNodeId } from "./utils/nodeRenumber.js";
|
||||
import { findOurSidebarButton, applySidebarOverride } from "./comfy/adapter.js";
|
||||
import { applySidebarOverride } from "./comfy/adapter.js";
|
||||
import { syncAllNodeBadges, setupVueNodeObserver, toggleIgnoreActiveNode } from "./comfy/nodes.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./comfy/layout.js";
|
||||
import { initSessionAndHistory, syncQueue } from "./queue/queueService.js";
|
||||
@@ -30,18 +31,6 @@ function setupKeydownShortcuts() {
|
||||
activeEl.tagName === "SELECT"
|
||||
)) return;
|
||||
|
||||
// Q: Toggle Queue Sidebar
|
||||
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (app.extensionManager?.command?.hasCommand?.("Workspace.ToggleSidebarTab.classic-comfy-sidebar")) {
|
||||
app.extensionManager.command.execute("Workspace.ToggleSidebarTab.classic-comfy-sidebar");
|
||||
} else {
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Q / Cmd+Q: Toggle Ignore Selected Node
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -183,6 +172,14 @@ function registerSettings() {
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.LATIN_HOTKEYS,
|
||||
name: t("Fix non-ASCII keybinds (hotkeys on any keyboard layout)"),
|
||||
tooltip: t("Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected."),
|
||||
type: "boolean",
|
||||
defaultValue: true
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.CLASSIC_LAYOUT,
|
||||
name: t("Places the controls and the open workflow tabs on a single unified top bar"),
|
||||
@@ -293,6 +290,14 @@ app.registerExtension({
|
||||
registerSettings();
|
||||
},
|
||||
|
||||
// Plain Q toggles the Queue tab through the frontend keybinding system (rebindable in Settings > Keybinding).
|
||||
keybindings: [
|
||||
{
|
||||
combo: { key: "q" },
|
||||
commandId: "Workspace.ToggleSidebarTab.classic-comfy-sidebar"
|
||||
}
|
||||
],
|
||||
|
||||
commands: [
|
||||
{
|
||||
id: "ComfySidebar.ToggleIgnoreNode",
|
||||
@@ -371,6 +376,7 @@ app.registerExtension({
|
||||
|
||||
cleanupFns.push(setupWorkflowTabsPositionWatcher());
|
||||
cleanupFns.push(setupDragAndDrop());
|
||||
cleanupFns.push(setupLatinHotkeys());
|
||||
cleanupFns.push(setupVueNodeObserver());
|
||||
|
||||
cleanupFns.push(setupExecutionTracker((pid, progress, nodeName) => {
|
||||
|
||||
+70
-23
@@ -17,6 +17,16 @@ let stickyHeaderGroupEl = null;
|
||||
let lastScrollTop = 0;
|
||||
let scrollDeltaAccumulator = 0;
|
||||
let isHeaderHidden = false;
|
||||
let galleryResizeObserver = null;
|
||||
|
||||
// Module-level so teardownGalleryView() can remove the exact same references
|
||||
const handleCtrlDown = (e) => {
|
||||
if (e.key === "Control" || e.key === "Meta") document.body.classList.add("comfy-sidebar-ctrl-active");
|
||||
};
|
||||
const handleCtrlUp = (e) => {
|
||||
if (e.key === "Control" || e.key === "Meta") document.body.classList.remove("comfy-sidebar-ctrl-active");
|
||||
};
|
||||
const handleCtrlBlur = () => document.body.classList.remove("comfy-sidebar-ctrl-active");
|
||||
|
||||
// Persistent sort order
|
||||
let sortOrder = "newest";
|
||||
@@ -45,6 +55,14 @@ function updateScrollTopVisibility() {
|
||||
}
|
||||
const scrollEl = getScrollContainer(cardStackEl);
|
||||
if (scrollEl) {
|
||||
const viewportEl = scrollEl.parentElement || galleryContainerEl;
|
||||
if (viewportEl && scrollToTopBtnEl && scrollToTopBtnEl.parentNode !== viewportEl) {
|
||||
if (window.getComputedStyle(viewportEl).position === "static") {
|
||||
viewportEl.style.position = "relative";
|
||||
}
|
||||
viewportEl.appendChild(scrollToTopBtnEl);
|
||||
}
|
||||
|
||||
if (scrollToTopBtnEl) {
|
||||
scrollToTopBtnEl.style.display = scrollEl.scrollTop > 200 ? "flex" : "none";
|
||||
}
|
||||
@@ -61,15 +79,21 @@ function updateScrollTopVisibility() {
|
||||
function handleHeaderScroll(scrollEl) {
|
||||
if (!stickyHeaderGroupEl) return;
|
||||
|
||||
// Never hide header if search bar is currently active
|
||||
const currentScroll = scrollEl.scrollTop;
|
||||
const searchContainer = stickyHeaderGroupEl.querySelector(".comfy-sidebar-search-container");
|
||||
if (currentSearchQuery || (searchContainer && searchContainer.style.display !== "none")) {
|
||||
const searchIsOpen = currentSearchQuery || (searchContainer && searchContainer.style.display !== "none");
|
||||
|
||||
// Keep the frosted surface in sync even while search forces the header to stay visible.
|
||||
stickyHeaderGroupEl.classList.toggle("is-scrolled", currentScroll > 35);
|
||||
|
||||
if (searchIsOpen) {
|
||||
lastScrollTop = currentScroll;
|
||||
scrollDeltaAccumulator = 0;
|
||||
stickyHeaderGroupEl.style.transform = "translateY(0)";
|
||||
isHeaderHidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentScroll = scrollEl.scrollTop;
|
||||
const delta = currentScroll - lastScrollTop;
|
||||
lastScrollTop = currentScroll;
|
||||
|
||||
@@ -107,14 +131,23 @@ function handleHeaderScroll(scrollEl) {
|
||||
}
|
||||
}
|
||||
|
||||
let galleryFetchController = null;
|
||||
|
||||
// Returns false only when the request was superseded by a newer one (aborted),
|
||||
// so callers can skip rendering stale results.
|
||||
async function fetchGalleryData(subfolder = "", searchQuery = "") {
|
||||
// A newer request (e.g. the next keystroke in the search box) cancels the in-flight one;
|
||||
// the server stops scanning as soon as the client disconnects.
|
||||
if (galleryFetchController) galleryFetchController.abort();
|
||||
const controller = new AbortController();
|
||||
galleryFetchController = controller;
|
||||
try {
|
||||
isLoading = true;
|
||||
const normalized = subfolder.replace(/\\/g, "/");
|
||||
const queryParams = new URLSearchParams({ subfolder: normalized });
|
||||
if (searchQuery) queryParams.set("search", searchQuery);
|
||||
|
||||
const res = await fetch(`/comfy-sidebar/gallery/list?${queryParams.toString()}`);
|
||||
const res = await fetch(`/comfy-sidebar/gallery/list?${queryParams.toString()}`, { signal: controller.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
cachedFolders = data.folders || [];
|
||||
@@ -123,11 +156,16 @@ async function fetchGalleryData(subfolder = "", searchQuery = "") {
|
||||
applySorting();
|
||||
displayedFilesCount = 0;
|
||||
} catch (e) {
|
||||
if (e?.name === "AbortError") return false;
|
||||
console.error("[ComfySidebar] Failed to load gallery", e);
|
||||
showNotification(t("Failed to load gallery from disk."), "error");
|
||||
} finally {
|
||||
isLoading = false;
|
||||
if (galleryFetchController === controller) {
|
||||
galleryFetchController = null;
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applySorting() {
|
||||
@@ -588,6 +626,7 @@ export function setupGalleryView() {
|
||||
};
|
||||
|
||||
let searchDebounce = null;
|
||||
let searchSeq = 0;
|
||||
const closeSearch = () => {
|
||||
const hadFilter = Boolean(currentSearchQuery || sInput.value.trim());
|
||||
sInput.value = "";
|
||||
@@ -618,12 +657,15 @@ export function setupGalleryView() {
|
||||
|
||||
sIcon.className = "pi pi-spin pi-spinner";
|
||||
|
||||
const seq = ++searchSeq;
|
||||
searchDebounce = setTimeout(async () => {
|
||||
let applied = false;
|
||||
try {
|
||||
await fetchGalleryData(currentSubfolder, currentSearchQuery);
|
||||
renderGallery();
|
||||
applied = (await fetchGalleryData(currentSubfolder, currentSearchQuery)) !== false;
|
||||
if (applied) renderGallery();
|
||||
} finally {
|
||||
sIcon.className = "pi pi-search";
|
||||
// Only the latest search may stop the spinner
|
||||
if (seq === searchSeq) sIcon.className = "pi pi-search";
|
||||
}
|
||||
}, 350);
|
||||
};
|
||||
@@ -636,19 +678,13 @@ export function setupGalleryView() {
|
||||
const cardStack = document.createElement("div");
|
||||
cardStackEl = cardStack;
|
||||
Object.assign(cardStack.style, {
|
||||
flex: "1", overflowY: "visible", display: "flex", flexDirection: "column", paddingBottom: "28px"
|
||||
flex: "1", overflowY: "visible", display: "flex", flexDirection: "column", paddingBottom: "46px"
|
||||
});
|
||||
container.appendChild(cardStack);
|
||||
|
||||
scrollToTopBtnEl = document.createElement("button");
|
||||
scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn";
|
||||
scrollToTopBtnEl.title = t("Scroll to Top");
|
||||
Object.assign(scrollToTopBtnEl.style, {
|
||||
position: "sticky",
|
||||
bottom: "14px",
|
||||
marginLeft: "auto",
|
||||
zIndex: "30"
|
||||
});
|
||||
scrollToTopBtnEl.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const scrollEl = getScrollContainer(cardStackEl);
|
||||
@@ -656,28 +692,24 @@ export function setupGalleryView() {
|
||||
};
|
||||
container.appendChild(scrollToTopBtnEl);
|
||||
|
||||
const handleCtrlDown = (e) => {
|
||||
if (e.key === "Control" || e.key === "Meta") document.body.classList.add("comfy-sidebar-ctrl-active");
|
||||
};
|
||||
const handleCtrlUp = (e) => {
|
||||
if (e.key === "Control" || e.key === "Meta") document.body.classList.remove("comfy-sidebar-ctrl-active");
|
||||
};
|
||||
window.addEventListener("keydown", handleCtrlDown);
|
||||
window.addEventListener("keyup", handleCtrlUp);
|
||||
window.addEventListener("blur", handleCtrlBlur);
|
||||
|
||||
// Reveal Spotlight: track cursor position over Gallery card and folder borders
|
||||
setupSpotlightTracker(container, ".comfy-sidebar-card, .comfy-sidebar-folder-card");
|
||||
|
||||
document.addEventListener("scroll", updateScrollTopVisibility, { capture: true, passive: true });
|
||||
|
||||
new ResizeObserver((entries) => {
|
||||
galleryResizeObserver = new ResizeObserver((entries) => {
|
||||
const threshold = getSetting(SettingIds.GRID_COLUMNS_THRESHOLD, 350);
|
||||
const cols = calculateGridColumns(entries[0].contentRect.width, threshold);
|
||||
if (cols !== currentColumnCount) {
|
||||
currentColumnCount = cols;
|
||||
renderGallery();
|
||||
}
|
||||
}).observe(container);
|
||||
});
|
||||
galleryResizeObserver.observe(container);
|
||||
|
||||
fetchGalleryData("").then(() => renderGallery());
|
||||
|
||||
@@ -686,10 +718,25 @@ export function setupGalleryView() {
|
||||
|
||||
export function teardownGalleryView() {
|
||||
document.removeEventListener("scroll", updateScrollTopVisibility, { capture: true, passive: true });
|
||||
window.removeEventListener("keydown", handleCtrlDown);
|
||||
window.removeEventListener("keyup", handleCtrlUp);
|
||||
window.removeEventListener("blur", handleCtrlBlur);
|
||||
document.body.classList.remove("comfy-sidebar-ctrl-active");
|
||||
if (galleryResizeObserver) {
|
||||
galleryResizeObserver.disconnect();
|
||||
galleryResizeObserver = null;
|
||||
}
|
||||
// Reset header-reveal state so a re-opened gallery starts with a visible header
|
||||
lastScrollTop = 0;
|
||||
scrollDeltaAccumulator = 0;
|
||||
isHeaderHidden = false;
|
||||
cachedFolders = [];
|
||||
cachedFiles = [];
|
||||
stickyHeaderGroupEl = null;
|
||||
galleryContainerEl = null;
|
||||
cardStackEl = null;
|
||||
if (scrollToTopBtnEl && scrollToTopBtnEl.parentNode) {
|
||||
scrollToTopBtnEl.parentNode.removeChild(scrollToTopBtnEl);
|
||||
}
|
||||
scrollToTopBtnEl = null;
|
||||
}
|
||||
+69
-2
@@ -17,6 +17,10 @@ let globalClickRegistered = false;
|
||||
let renderAnimationFrameId = null;
|
||||
let activeTimerInterval = null;
|
||||
let currentColumnCount = 1;
|
||||
let stickyHeaderGroupEl = null;
|
||||
let lastScrollTop = 0;
|
||||
let scrollDeltaAccumulator = 0;
|
||||
let isHeaderHidden = false;
|
||||
|
||||
function mountCards(cardStack, fullWidthEls, cardEls, cols = 1) {
|
||||
if (!cardStack) return;
|
||||
@@ -91,6 +95,58 @@ function mountCards(cardStack, fullWidthEls, cardEls, cols = 1) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleQueueHeaderScroll(scrollEl) {
|
||||
if (!stickyHeaderGroupEl) return;
|
||||
|
||||
const currentScroll = scrollEl.scrollTop;
|
||||
const searchContainer = stickyHeaderGroupEl.querySelector(".comfy-sidebar-search-container");
|
||||
const searchIsOpen = searchContainer && searchContainer.style.display !== "none";
|
||||
|
||||
// Keep the frosted surface visible while search is open, matching Gallery.
|
||||
stickyHeaderGroupEl.classList.toggle("is-scrolled", currentScroll > 35);
|
||||
|
||||
if (searchIsOpen) {
|
||||
lastScrollTop = currentScroll;
|
||||
scrollDeltaAccumulator = 0;
|
||||
stickyHeaderGroupEl.style.transform = "translateY(0)";
|
||||
isHeaderHidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = currentScroll - lastScrollTop;
|
||||
lastScrollTop = currentScroll;
|
||||
|
||||
if (currentScroll <= 35) {
|
||||
stickyHeaderGroupEl.classList.remove("is-scrolled");
|
||||
if (isHeaderHidden) {
|
||||
stickyHeaderGroupEl.style.transform = "translateY(0)";
|
||||
isHeaderHidden = false;
|
||||
}
|
||||
scrollDeltaAccumulator = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
stickyHeaderGroupEl.classList.add("is-scrolled");
|
||||
|
||||
if (delta > 0) {
|
||||
if (scrollDeltaAccumulator < 0) scrollDeltaAccumulator = 0;
|
||||
scrollDeltaAccumulator += delta;
|
||||
|
||||
if (scrollDeltaAccumulator > 25 && !isHeaderHidden) {
|
||||
stickyHeaderGroupEl.style.transform = "translateY(-100%)";
|
||||
isHeaderHidden = true;
|
||||
}
|
||||
} else if (delta < 0) {
|
||||
if (scrollDeltaAccumulator > 0) scrollDeltaAccumulator = 0;
|
||||
scrollDeltaAccumulator += delta;
|
||||
|
||||
if (scrollDeltaAccumulator < -15 && isHeaderHidden) {
|
||||
stickyHeaderGroupEl.style.transform = "translateY(0)";
|
||||
isHeaderHidden = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateScrollTopBtnVisibility() {
|
||||
if (!scrollToTopBtnEl) return;
|
||||
if (!store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) {
|
||||
@@ -112,6 +168,8 @@ function updateScrollTopBtnVisibility() {
|
||||
} else {
|
||||
scrollToTopBtnEl.style.display = "none";
|
||||
}
|
||||
|
||||
handleQueueHeaderScroll(scrollEl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +363,12 @@ export function setupSidebarView() {
|
||||
|
||||
header.appendChild(standardHeader);
|
||||
header.appendChild(searchContainer);
|
||||
container.appendChild(header);
|
||||
|
||||
const stickyHeaderGroup = document.createElement("div");
|
||||
stickyHeaderGroup.className = "comfy-sidebar-sticky-header-group";
|
||||
stickyHeaderGroupEl = stickyHeaderGroup;
|
||||
stickyHeaderGroup.appendChild(header);
|
||||
container.appendChild(stickyHeaderGroup);
|
||||
|
||||
searchIcon.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -336,7 +399,7 @@ export function setupSidebarView() {
|
||||
overflowY: "visible",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingBottom: "28px"
|
||||
paddingBottom: "46px"
|
||||
});
|
||||
container.appendChild(cardStack);
|
||||
|
||||
@@ -997,4 +1060,8 @@ export function teardownSidebarView() {
|
||||
renderAnimationFrameId = null;
|
||||
}
|
||||
cardPool.clear();
|
||||
if (scrollToTopBtnEl && scrollToTopBtnEl.parentNode) {
|
||||
scrollToTopBtnEl.parentNode.removeChild(scrollToTopBtnEl);
|
||||
}
|
||||
scrollToTopBtnEl = null;
|
||||
}
|
||||
+19
-6
@@ -328,6 +328,7 @@ function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () =>
|
||||
const createMediaElement = (src, muted = false) => {
|
||||
const isVideo = isVideoFormat(src);
|
||||
const el = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
el.className = "comfy-sidebar-comparison-media";
|
||||
if (isVideo) {
|
||||
el.muted = muted;
|
||||
el.playsInline = true;
|
||||
@@ -345,7 +346,10 @@ const createMediaElement = (src, muted = false) => {
|
||||
pointerEvents: isVideo ? "none" : "auto",
|
||||
userSelect: "none",
|
||||
webkitUserSelect: "none",
|
||||
display: "block"
|
||||
display: "block",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
boxShadow: "none"
|
||||
});
|
||||
el.draggable = false;
|
||||
el.ondragstart = (e) => e.preventDefault();
|
||||
@@ -576,21 +580,30 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
}
|
||||
|
||||
const slider = document.createElement("div");
|
||||
slider.className = "comfy-sidebar-comparison-slider";
|
||||
Object.assign(slider.style, {
|
||||
position: "absolute", top: "0", bottom: "0", left: "50%",
|
||||
width: "2px", background: "#fff", cursor: "ew-resize",
|
||||
width: "2px",
|
||||
background: "var(--foreground, #ffffff)",
|
||||
cursor: "ew-resize",
|
||||
zIndex: "25", display: "none", pointerEvents: "auto",
|
||||
boxShadow: "0 0 8px rgba(0,0,0,0.5)"
|
||||
boxShadow: "0 0 6px rgba(0,0,0,0.6)"
|
||||
});
|
||||
const sliderButton = document.createElement("div");
|
||||
sliderButton.className = "comfy-sidebar-comparison-handle";
|
||||
Object.assign(sliderButton.style, {
|
||||
position: "absolute", top: "50%", left: "50%",
|
||||
transform: "translate(-50%, -50%)", width: "32px", height: "32px",
|
||||
borderRadius: "50%", background: "#fff", color: "#333",
|
||||
borderRadius: "50%",
|
||||
background: "var(--card, var(--secondary, rgba(30, 30, 30, 0.9)))",
|
||||
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
|
||||
color: "var(--foreground, #eee)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", pointerEvents: "auto"
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.5)", pointerEvents: "auto",
|
||||
cursor: "ew-resize",
|
||||
transition: "background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, color 0.15s ease-in-out"
|
||||
});
|
||||
sliderButton.innerHTML = `<span class="pi pi-arrows-h" style="font-size: 12px;"></span>`;
|
||||
sliderButton.innerHTML = `<span class="pi pi-arrows-h" style="font-size: 13px; pointer-events: none;"></span>`;
|
||||
slider.appendChild(sliderButton);
|
||||
wrapper.appendChild(slider);
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { SettingIds } from "../core/constants.js";
|
||||
import { getSetting } from "../core/settings.js";
|
||||
|
||||
/**
|
||||
* Makes hotkeys work regardless of the active keyboard layout.
|
||||
*
|
||||
* ComfyUI's keybinding system matches on `KeyboardEvent.key`, which follows the active layout:
|
||||
* with a Russian layout, physical Q produces "й", so a binding on "q" never fires.
|
||||
* `KeyboardEvent.code` is the physical key ("KeyQ") and does not change with the layout.
|
||||
*
|
||||
* Strategy: in the capture phase on `window` (which runs before ComfyUI's own `keydown` listener),
|
||||
* if `key` is a single non-ASCII character, swallow the event and re-dispatch an identical one
|
||||
* whose `key` is the US-QWERTY character for that physical key. Everything downstream (keybindings,
|
||||
* LiteGraph shortcuts, other extensions) then sees plain Latin keys.
|
||||
*
|
||||
* Deliberately NOT touched:
|
||||
* - layouts that already report ASCII keys (QWERTY, AZERTY, Dvorak...), so their users keep
|
||||
* their own mapping;
|
||||
* - text fields (input / textarea / select / contenteditable / LiteGraph property values),
|
||||
* so typing in any language keeps working;
|
||||
* - IME composition;
|
||||
* - named keys such as "Enter", "Escape", "Dead".
|
||||
*/
|
||||
|
||||
// Unshifted / shifted characters of the US-QWERTY layout for non-letter keys.
|
||||
const SYMBOL_KEYS = {
|
||||
Backquote: ["`", "~"],
|
||||
Minus: ["-", "_"],
|
||||
Equal: ["=", "+"],
|
||||
BracketLeft: ["[", "{"],
|
||||
BracketRight: ["]", "}"],
|
||||
Backslash: ["\\", "|"],
|
||||
Semicolon: [";", ":"],
|
||||
Quote: ["'", "\""],
|
||||
Comma: [",", "<"],
|
||||
Period: [".", ">"],
|
||||
Slash: ["/", "?"],
|
||||
Digit1: ["1", "!"],
|
||||
Digit2: ["2", "@"],
|
||||
Digit3: ["3", "#"],
|
||||
Digit4: ["4", "$"],
|
||||
Digit5: ["5", "%"],
|
||||
Digit6: ["6", "^"],
|
||||
Digit7: ["7", "&"],
|
||||
Digit8: ["8", "*"],
|
||||
Digit9: ["9", "("],
|
||||
Digit0: ["0", ")"]
|
||||
};
|
||||
|
||||
/** Returns the US-QWERTY `key` for the physical key of this event, or null if unknown. */
|
||||
export function latinKeyFromEvent(e) {
|
||||
const letter = /^Key([A-Z])$/.exec(e.code || "");
|
||||
if (letter) return e.shiftKey ? letter[1] : letter[1].toLowerCase();
|
||||
|
||||
const pair = SYMBOL_KEYS[e.code];
|
||||
if (pair) return e.shiftKey ? pair[1] : pair[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSingleNonAsciiChar(key) {
|
||||
if (typeof key !== "string") return false;
|
||||
const chars = Array.from(key);
|
||||
return chars.length === 1 && chars[0].codePointAt(0) > 127;
|
||||
}
|
||||
|
||||
function isTextEntryTarget(el) {
|
||||
if (!el || el.nodeType !== 1) return false;
|
||||
const tag = el.tagName;
|
||||
return (
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
el.isContentEditable ||
|
||||
// LiteGraph's inline value editors are spans
|
||||
(tag === "SPAN" && el.classList.contains("property_value"))
|
||||
);
|
||||
}
|
||||
|
||||
export function setupLatinHotkeys() {
|
||||
// Events we dispatched ourselves; used to avoid handling them again.
|
||||
const synthetic = new WeakSet();
|
||||
|
||||
const handler = (e) => {
|
||||
if (synthetic.has(e)) return;
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
if (!isSingleNonAsciiChar(e.key)) return;
|
||||
if (!getSetting(SettingIds.LATIN_HOTKEYS, true)) return;
|
||||
|
||||
const latinKey = latinKeyFromEvent(e);
|
||||
if (!latinKey) return;
|
||||
|
||||
const target = e.composedPath?.()[0] ?? e.target;
|
||||
if (isTextEntryTarget(target)) return;
|
||||
|
||||
const clone = new KeyboardEvent(e.type, {
|
||||
key: latinKey,
|
||||
code: e.code,
|
||||
location: e.location,
|
||||
repeat: e.repeat,
|
||||
ctrlKey: e.ctrlKey,
|
||||
shiftKey: e.shiftKey,
|
||||
altKey: e.altKey,
|
||||
metaKey: e.metaKey,
|
||||
keyCode: e.keyCode,
|
||||
which: e.which,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
view: window
|
||||
});
|
||||
synthetic.add(clone);
|
||||
|
||||
e.stopImmediatePropagation();
|
||||
const notPrevented = (target || document).dispatchEvent(clone);
|
||||
if (!notPrevented) e.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler, true);
|
||||
window.addEventListener("keyup", handler, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handler, true);
|
||||
window.removeEventListener("keyup", handler, true);
|
||||
};
|
||||
}
|
||||
+13
-1
@@ -83,7 +83,13 @@ export class SidebarOverlay {
|
||||
// Live observer for opening/closing panels or layout changes
|
||||
if (window.MutationObserver) {
|
||||
let updateScheduled = false;
|
||||
const mo = new MutationObserver(() => {
|
||||
const mo = new MutationObserver((mutations) => {
|
||||
// Skip mutations caused purely by the sticky header's scroll animation
|
||||
const relevant = mutations.some((m) => {
|
||||
const el = m.target.nodeType === 1 ? m.target : m.target.parentElement;
|
||||
return !el?.closest?.(".comfy-sidebar-sticky-header-group");
|
||||
});
|
||||
if (!relevant) return;
|
||||
if (!updateScheduled) {
|
||||
updateScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
@@ -118,6 +124,8 @@ export class SidebarOverlay {
|
||||
}
|
||||
|
||||
updateOverlayBounds() {
|
||||
// A pending rAF/ResizeObserver callback can fire after destroy(); don't re-attach observers
|
||||
if (this.destroyed) return;
|
||||
let leftOffset = 0;
|
||||
let rightOffset = 0;
|
||||
let topOffset = 0;
|
||||
@@ -129,6 +137,8 @@ export class SidebarOverlay {
|
||||
// Dynamically detect any top header/tab bar docked to the top of the screen
|
||||
const potentialTopBars = document.querySelectorAll("header, .comfyui-header, .comfyui-menu, .comfy-menu, [class*='tab-bar'], [class*='workflow-tab'], [class*='workspace-tab'], [class*='header']");
|
||||
for (const bar of potentialTopBars) {
|
||||
// Ignore our own sliding gallery/queue header (it animates via translateY)
|
||||
if (bar.closest(".comfy-sidebar-sticky-header-group, .comfy-sidebar-header-root")) continue;
|
||||
// Must be an element sitting at the top of the viewport
|
||||
const r = bar.getBoundingClientRect();
|
||||
if (r.top <= 2 && r.bottom > 20 && r.bottom < 120 && r.width > 200) {
|
||||
@@ -178,6 +188,8 @@ export class SidebarOverlay {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
document.body.classList.remove("comfy-sidebar-overlay-active");
|
||||
|
||||
for (const fn of this.cleanupFns.splice(0)) {
|
||||
|
||||
+47
-15
@@ -614,26 +614,58 @@ export function injectStyles() {
|
||||
-webkit-backdrop-filter: blur(10px) !important;
|
||||
}
|
||||
|
||||
/* Round (X) Close Button across all preview overlays (matches button, div, or span) */
|
||||
body.comfy-sidebar-overlay-active [style*="50%"],
|
||||
.comfy-sidebar-comparison-overlay [style*="50%"],
|
||||
.comfy-sidebar-3d-overlay-root [style*="50%"],
|
||||
div[class*="sidebar-overlay"] [style*="50%"] {
|
||||
background: var(--card, var(--secondary, rgba(28, 28, 35, 0.9))) !important;
|
||||
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.2))) !important;
|
||||
color: var(--muted-foreground, #cbd5e1) !important;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5) !important;
|
||||
/* Comparison Media: Ensures no borders or outlines appear on compared images */
|
||||
.comfy-sidebar-comparison-media {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Comparison Slider Line & Handle */
|
||||
.comfy-sidebar-comparison-slider {
|
||||
background: var(--foreground, #ffffff) !important;
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-comparison-handle {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border-radius: 50% !important;
|
||||
background: var(--card, var(--secondary, rgba(30, 30, 30, 0.9))) !important;
|
||||
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15))) !important;
|
||||
color: var(--foreground, #eee) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important;
|
||||
cursor: ew-resize !important;
|
||||
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, color 0.15s ease-in-out !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-comparison-handle:hover {
|
||||
background: var(--primary, var(--p-primary-color, #ea580c)) !important;
|
||||
border-color: var(--primary, var(--p-primary-color, #ea580c)) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Round (X) Close Button across all preview overlays (matches buttons, ignores images/sliders) */
|
||||
body.comfy-sidebar-overlay-active button[style*="50%"],
|
||||
body.comfy-sidebar-overlay-active [style*="border-radius: 50%"]:not(img):not(video):not(.comfy-sidebar-comparison-handle):not(.comfy-sidebar-comparison-slider):not(.comfy-sidebar-comparison-media),
|
||||
body.comfy-sidebar-overlay-active [style*="border-radius:50%"]:not(img):not(video):not(.comfy-sidebar-comparison-handle):not(.comfy-sidebar-comparison-slider):not(.comfy-sidebar-comparison-media),
|
||||
.comfy-sidebar-comparison-overlay button[style*="50%"],
|
||||
div[class*="sidebar-overlay"] button[style*="50%"] {
|
||||
background: var(--card, var(--secondary, rgba(30, 30, 30, 0.9))) !important;
|
||||
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15))) !important;
|
||||
color: var(--foreground, #eee) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important;
|
||||
transition: all 0.15s ease-in-out !important;
|
||||
}
|
||||
|
||||
body.comfy-sidebar-overlay-active [style*="50%"]:hover,
|
||||
.comfy-sidebar-comparison-overlay [style*="50%"]:hover,
|
||||
.comfy-sidebar-3d-overlay-root [style*="50%"]:hover,
|
||||
div[class*="sidebar-overlay"] [style*="50%"]:hover {
|
||||
body.comfy-sidebar-overlay-active button[style*="50%"]:hover,
|
||||
body.comfy-sidebar-overlay-active [style*="border-radius: 50%"]:not(img):not(video):not(.comfy-sidebar-comparison-handle):not(.comfy-sidebar-comparison-slider):not(.comfy-sidebar-comparison-media):hover,
|
||||
body.comfy-sidebar-overlay-active [style*="border-radius:50%"]:not(img):not(video):not(.comfy-sidebar-comparison-handle):not(.comfy-sidebar-comparison-slider):not(.comfy-sidebar-comparison-media):hover,
|
||||
.comfy-sidebar-comparison-overlay button[style*="50%"]:hover,
|
||||
div[class*="sidebar-overlay"] button[style*="50%"]:hover {
|
||||
color: #ffffff !important;
|
||||
border-color: var(--primary, var(--p-primary-color, #ea580c)) !important;
|
||||
background: color-mix(in srgb, var(--primary, var(--p-primary-color, #ea580c)) 25%, var(--card, var(--secondary, #222)) 75%) !important;
|
||||
transform: scale(1.08) !important;
|
||||
background: var(--primary, var(--p-primary-color, #ea580c)) !important;
|
||||
}
|
||||
|
||||
body.comfy-sidebar-overlay-active [style*="50%"] svg,
|
||||
|
||||
@@ -426,6 +426,7 @@ export function matchesFilter(state, query) {
|
||||
|
||||
if (state.pid && String(state.pid).toLowerCase().includes(q)) return true;
|
||||
if (state.texts && state.texts.some(t => String(t).toLowerCase().includes(q))) return true;
|
||||
if (state.promptSearchText && state.promptSearchText.includes(q)) return true;
|
||||
if (state.images && state.images.some(img => (img.filename || "").toLowerCase().includes(q))) return true;
|
||||
if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) return true;
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Extract searchable prompt text from ComfyUI prompt graphs and PNG outputs.
|
||||
|
||||
This module has no ComfyUI imports, so it can be used and tested on its own.
|
||||
|
||||
How it works
|
||||
------------
|
||||
* Only the PNG ``prompt`` text chunk is read. Other chunks (notably the often
|
||||
multi-megabyte ``workflow`` chunk) are skipped with a seek, never parsed.
|
||||
* From API-format prompt graphs (whether supplied by Queue or read from PNGs),
|
||||
collect only the strings that actually
|
||||
feed a text-encoder node (``CLIPTextEncode`` and friends), following links to
|
||||
upstream string/primitive/concat nodes. Model names, sampler names, file
|
||||
names and other string widgets are ignored.
|
||||
* Extracted text is cached per file (keyed by mtime + size), so repeated
|
||||
searches only pay for files that are new or changed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import zlib
|
||||
from collections import OrderedDict
|
||||
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
# Input names that can carry prompt text (substring match, lower-case).
|
||||
# Deliberately does not match plain "clip", "model", "vae", "image", etc.
|
||||
_PROMPT_KEY_HINTS = (
|
||||
"text", "prompt", "positive", "negative", "caption",
|
||||
"string", "value", "t5xxl", "clip_l", "clip_g",
|
||||
)
|
||||
_MAX_LINK_DEPTH = 6
|
||||
_MAX_PROMPT_CHUNK_BYTES = 16 * 1024 * 1024
|
||||
_MAX_TEXT_CHARS_PER_FILE = 50_000
|
||||
|
||||
_CACHE_MAX_ENTRIES = 20_000
|
||||
_cache = OrderedDict() # path -> ((mtime_ns, size), lowercased prompt text)
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Prompt graph -> text
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _is_prompt_key(name):
|
||||
lowered = str(name).lower()
|
||||
return any(hint in lowered for hint in _PROMPT_KEY_HINTS)
|
||||
|
||||
|
||||
def _is_encoder(class_type):
|
||||
lowered = str(class_type or "").lower()
|
||||
return "textencode" in lowered or "prompt" in lowered
|
||||
|
||||
|
||||
def _is_link(value):
|
||||
# API-format link: [source_node_id, output_slot]
|
||||
return isinstance(value, list) and len(value) == 2 and isinstance(value[1], int)
|
||||
|
||||
|
||||
def extract_prompt_texts(prompt):
|
||||
"""Return the prompt strings found in an API-format ComfyUI prompt dict."""
|
||||
if not isinstance(prompt, dict):
|
||||
return []
|
||||
|
||||
texts = []
|
||||
seen = set()
|
||||
|
||||
def visit(node_id, depth):
|
||||
key = str(node_id)
|
||||
if key in seen or depth > _MAX_LINK_DEPTH:
|
||||
return
|
||||
seen.add(key)
|
||||
node = prompt.get(key)
|
||||
inputs = node.get("inputs") if isinstance(node, dict) else None
|
||||
if not isinstance(inputs, dict):
|
||||
return
|
||||
for name, value in inputs.items():
|
||||
if not _is_prompt_key(name):
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
if value.strip():
|
||||
texts.append(value)
|
||||
elif _is_link(value):
|
||||
visit(value[0], depth + 1)
|
||||
|
||||
starts = [
|
||||
node_id for node_id, node in prompt.items()
|
||||
if isinstance(node, dict) and _is_encoder(node.get("class_type"))
|
||||
]
|
||||
if not starts:
|
||||
# Unusual graph with no recognisable text-encoder node: fall back to
|
||||
# every node, still restricted to prompt-like input names.
|
||||
starts = list(prompt)
|
||||
for node_id in starts:
|
||||
visit(node_id, 0)
|
||||
return texts
|
||||
|
||||
|
||||
def get_prompt_search_text_from_graph(prompt):
|
||||
"""Return lower-cased text-encoder prompt text from an API-format graph."""
|
||||
return "\n".join(extract_prompt_texts(prompt)).lower()[:_MAX_TEXT_CHARS_PER_FILE]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PNG chunk reading
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _inflate(data, limit=_MAX_PROMPT_CHUNK_BYTES):
|
||||
# Bounded decompress: refuses to expand beyond `limit` bytes.
|
||||
return zlib.decompressobj().decompress(data, limit)
|
||||
|
||||
|
||||
def _decode_text_chunk(chunk_type, data, nul):
|
||||
"""Decode tEXt / zTXt / iTXt chunk data (keyword already located at `nul`)."""
|
||||
if chunk_type == b"tEXt":
|
||||
return data[nul + 1:].decode("latin-1")
|
||||
|
||||
if chunk_type == b"zTXt":
|
||||
if data[nul + 1] != 0: # only compression method 0 (zlib) exists
|
||||
return None
|
||||
return _inflate(data[nul + 2:]).decode("latin-1")
|
||||
|
||||
# iTXt: keyword\0 compression_flag compression_method language\0 translated\0 text
|
||||
flag = data[nul + 1]
|
||||
rest = data[nul + 3:]
|
||||
lang_end = rest.index(b"\x00")
|
||||
translated_end = rest.index(b"\x00", lang_end + 1)
|
||||
body = rest[translated_end + 1:]
|
||||
if flag == 1:
|
||||
body = _inflate(body)
|
||||
return body.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def read_png_prompt(fileobj):
|
||||
"""Return the parsed ``prompt`` JSON from an open PNG file, or None."""
|
||||
if fileobj.read(8) != _PNG_SIGNATURE:
|
||||
return None
|
||||
while True:
|
||||
header = fileobj.read(8)
|
||||
if len(header) < 8:
|
||||
return None
|
||||
length = int.from_bytes(header[:4], "big")
|
||||
chunk_type = header[4:]
|
||||
if chunk_type in (b"IDAT", b"IEND"):
|
||||
return None # ComfyUI writes its text chunks before the image data
|
||||
|
||||
if chunk_type in (b"tEXt", b"zTXt", b"iTXt"):
|
||||
head = fileobj.read(min(length, 80)) # keyword is at most 79 bytes
|
||||
nul = head.find(b"\x00")
|
||||
if nul != -1 and head[:nul] == b"prompt":
|
||||
if length > _MAX_PROMPT_CHUNK_BYTES:
|
||||
return None
|
||||
data = head + fileobj.read(length - len(head))
|
||||
text = _decode_text_chunk(chunk_type, data, nul)
|
||||
return json.loads(text) if text else None
|
||||
fileobj.seek(length - len(head) + 4, 1) # rest of data + CRC
|
||||
else:
|
||||
fileobj.seek(length + 4, 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Cached per-file text
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def get_prompt_search_text(path):
|
||||
"""Lower-cased prompt text of a PNG ('' if none). Cached by mtime + size."""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return ""
|
||||
signature = (st.st_mtime_ns, st.st_size)
|
||||
|
||||
with _cache_lock:
|
||||
hit = _cache.get(path)
|
||||
if hit and hit[0] == signature:
|
||||
_cache.move_to_end(path)
|
||||
return hit[1]
|
||||
|
||||
text = ""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
prompt = read_png_prompt(f)
|
||||
if prompt is not None:
|
||||
text = get_prompt_search_text_from_graph(prompt)
|
||||
except Exception:
|
||||
# Corrupt / truncated / non-ComfyUI PNG: treat as "no prompt".
|
||||
text = ""
|
||||
|
||||
with _cache_lock:
|
||||
_cache[path] = (signature, text)
|
||||
_cache.move_to_end(path)
|
||||
while len(_cache) > _CACHE_MAX_ENTRIES:
|
||||
_cache.popitem(last=False)
|
||||
return text
|
||||
|
||||
|
||||
def clear_cache():
|
||||
with _cache_lock:
|
||||
_cache.clear()
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfy-sidebar"
|
||||
description = "A compact and powerful layout overhaul for ComfyUI that makes generation results easier to browse, preview, compare, and manage."
|
||||
version = "1.8.2"
|
||||
version = "1.8.3"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"send2trash>=1.8.0",
|
||||
|
||||
Reference in New Issue
Block a user