diff --git a/README.md b/README.md index bcc31da..ff99fa7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ A compact and powerful layout overhaul for ComfyUI that makes generation results ComfySidebar compare +* **[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. + * **Runtime preview mode** combined with workflow overview makes ComfyUI practical for running and monitoring workflows in a more streamlined, queue-oriented way. ComfySidebar runtime mode @@ -58,6 +60,7 @@ Open **Settings → Comfy Sidebar** to configure: * Graph button visibility * Unified top bar layout * Permanent deletion behavior +* Output Gallery sidebar toggle ## ⌨️ Controls diff --git a/__init__.py b/__init__.py index bdcae9b..644be37 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,7 @@ import os import platform import subprocess +import json from aiohttp import web from server import PromptServer import folder_paths @@ -117,6 +118,138 @@ 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 + + +@PromptServer.instance.routes.get("/comfy-sidebar/gallery/list") +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 + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + @PromptServer.instance.routes.post("/comfy-sidebar/open-folder") async def open_folder_handler(request): try: diff --git a/js/locales/ar/main.json b/js/locales/ar/main.json index aa1e7e1..d2daab7 100644 --- a/js/locales/ar/main.json +++ b/js/locales/ar/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "تجاهل/تضمين العقد المحددة في قائمة الانتظار", "Renumber All Nodes (Execution Order)": "إعادة ترقيم جميع العقد (ترتيب التنفيذ)", "Set Selected Node ID...": "تعيين معرف العقدة المحددة...", - "Set Node ID...": "تعيين معرف العقدة..." + "Set Node ID...": "تعيين معرف العقدة...", + "Gallery": "المعرض", + "Gallery (Outputs)": "المعرض (المخرجات)", + "Go Up One Level": "الانتقال لمستوى أعلى", + "Open Current Folder in File Manager": "فتح المجلد الحالي في مدير الملفات", + "Refresh Gallery": "تحديث المعرض", + "Filter gallery files...": "تصفية ملفات المعرض...", + "Loading files...": "جاري تحميل الملفات...", + "No files found in this folder.": "لم يتم العثور على ملفات في هذا المجلد.", + "Failed to load gallery from disk.": "فشل تحميل المعرض من القرص.", + "Sort: Newest First": "الترتيب: الأحدث أولاً", + "Sort: Oldest First": "الترتيب: الأقدم أولاً", + "Search Gallery (Filename or Prompt)": "البحث في المعرض (اسم الملف أو الموجه)", + "Search filenames or prompt metadata...": "البحث في أسماء الملفات أو بيانات الموجه..." } \ No newline at end of file diff --git a/js/locales/ar/settings.json b/js/locales/ar/settings.json index 74ba6c7..8c290e4 100644 --- a/js/locales/ar/settings.json +++ b/js/locales/ar/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "إخفاء العناصر غير المرغوبة", "Default": "افتراضي", "Dock": "إرساء", - "Hide": "إخفاء" + "Hide": "إخفاء", + "Enable Output Gallery Sidebar": "تمكين الشريط الجانبي لمعرض المخرجات" } \ No newline at end of file diff --git a/js/locales/en/main.json b/js/locales/en/main.json index 58b9f4e..e51bfb0 100644 --- a/js/locales/en/main.json +++ b/js/locales/en/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "Toggle Ignore Selected Node(s) in Queue", "Renumber All Nodes (Execution Order)": "Renumber All Nodes (Execution Order)", "Set Selected Node ID...": "Set Selected Node ID...", - "Set Node ID...": "Set Node ID..." + "Set Node ID...": "Set Node ID...", + "Gallery": "Gallery", + "Gallery (Outputs)": "Gallery (Outputs)", + "Go Up One Level": "Go Up One Level", + "Open Current Folder in File Manager": "Open Current Folder in File Manager", + "Refresh Gallery": "Refresh Gallery", + "Filter gallery files...": "Filter gallery files...", + "Loading files...": "Loading files...", + "No files found in this folder.": "No files found in this folder.", + "Failed to load gallery from disk.": "Failed to load gallery from disk.", + "Sort: Newest First": "Sort: Newest First", + "Sort: Oldest First": "Sort: Oldest First", + "Search Gallery (Filename or Prompt)": "Search Gallery (Filename or Prompt)", + "Search filenames or prompt metadata...": "Search filenames or prompt metadata..." } \ No newline at end of file diff --git a/js/locales/en/settings.json b/js/locales/en/settings.json index 086c722..598ae8f 100644 --- a/js/locales/en/settings.json +++ b/js/locales/en/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "Hide Junk", "Default": "Default", "Dock": "Dock", - "Hide": "Hide" + "Hide": "Hide", + "Enable Output Gallery Sidebar": "Enable Output Gallery Sidebar" } \ No newline at end of file diff --git a/js/locales/es/main.json b/js/locales/es/main.json index bfd52ad..2731a9f 100644 --- a/js/locales/es/main.json +++ b/js/locales/es/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "Ignorar/Incluir nodo(s) seleccionado(s) en la cola", "Renumber All Nodes (Execution Order)": "Renumerar todos los nodos (orden de ejecución)", "Set Selected Node ID...": "Establecer ID del nodo seleccionado...", - "Set Node ID...": "Establecer ID de nodo..." + "Set Node ID...": "Establecer ID de nodo...", + "Gallery": "Galería", + "Gallery (Outputs)": "Galería (Salidas)", + "Go Up One Level": "Subir un nivel", + "Open Current Folder in File Manager": "Abrir carpeta actual en el explorador de archivos", + "Refresh Gallery": "Actualizar galería", + "Filter gallery files...": "Filtrar archivos de la galería...", + "Loading files...": "Cargando archivos...", + "No files found in this folder.": "No se encontraron archivos en esta carpeta.", + "Failed to load gallery from disk.": "Error al cargar la galería desde el disco.", + "Sort: Newest First": "Ordenar: más recientes primero", + "Sort: Oldest First": "Ordenar: más antiguos primero", + "Search Gallery (Filename or Prompt)": "Buscar en galería (nombre de archivo o prompt)", + "Search filenames or prompt metadata...": "Buscar nombres de archivo o metadatos de prompt..." } \ No newline at end of file diff --git a/js/locales/es/settings.json b/js/locales/es/settings.json index ad4c82c..7c4e76e 100644 --- a/js/locales/es/settings.json +++ b/js/locales/es/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "Ocultar elementos innecesarios", "Default": "Por defecto", "Dock": "Acoplar", - "Hide": "Ocultar" + "Hide": "Ocultar", + "Enable Output Gallery Sidebar": "Habilitar barra lateral de galería de salidas" } \ No newline at end of file diff --git a/js/locales/fa/main.json b/js/locales/fa/main.json index 7dc881b..2b18326 100644 --- a/js/locales/fa/main.json +++ b/js/locales/fa/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "نادیده گرفتن/فعال‌سازی گره‌های انتخاب‌شده در صف", "Renumber All Nodes (Execution Order)": "شماره‌گذاری مجدد همه گره‌ها (ترتیب اجرا)", "Set Selected Node ID...": "تنظیم شناسه گره انتخاب‌شده...", - "Set Node ID...": "تنظیم شناسه گره..." + "Set Node ID...": "تنظیم شناسه گره...", + "Gallery": "گالری", + "Gallery (Outputs)": "گالری (خروجی‌ها)", + "Go Up One Level": "یک سطح به بالا", + "Open Current Folder in File Manager": "باز کردن پوشه فعلی در مدیریت فایل", + "Refresh Gallery": "به‌روزرسانی گالری", + "Filter gallery files...": "فیلتر کردن فایل‌های گالری...", + "Loading files...": "در حال بارگذاری فایل‌ها...", + "No files found in this folder.": "هیچ فایلی در این پوشه یافت نشد.", + "Failed to load gallery from disk.": "بارگذاری گالری از دیسک ناموفق بود.", + "Sort: Newest First": "مرتب‌سازی: جدیدترین‌ها در ابتدا", + "Sort: Oldest First": "مرتب‌سازی: قدیمی‌ترین‌ها در ابتدا", + "Search Gallery (Filename or Prompt)": "جستجو در گالری (نام فایل یا پرامپت)", + "Search filenames or prompt metadata...": "جستجوی نام فایل‌ها یا متادیتای پرامپت..." } \ No newline at end of file diff --git a/js/locales/fa/settings.json b/js/locales/fa/settings.json index 4090337..f07fbd7 100644 --- a/js/locales/fa/settings.json +++ b/js/locales/fa/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "پنهان کردن موارد غیرضروری", "Default": "پیش‌فرض", "Dock": "اتصال", - "Hide": "پنهان" + "Hide": "پنهان", + "Enable Output Gallery Sidebar": "فعال‌سازی نوار کناری گالری خروجی‌ها" } \ No newline at end of file diff --git a/js/locales/fr/main.json b/js/locales/fr/main.json index 3060bb4..f8a607d 100644 --- a/js/locales/fr/main.json +++ b/js/locales/fr/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "Ignorer/Activer les nœuds sélectionnés dans la file", "Renumber All Nodes (Execution Order)": "Renuméroter tous les nœuds (ordre d'exécution)", "Set Selected Node ID...": "Définir l'ID du nœud sélectionné...", - "Set Node ID...": "Définir l'ID du nœud..." + "Set Node ID...": "Définir l'ID du nœud...", + "Gallery": "Galerie", + "Gallery (Outputs)": "Galerie (Sorties)", + "Go Up One Level": "Dossier parent", + "Open Current Folder in File Manager": "Ouvrir le dossier actuel dans le gestionnaire de fichiers", + "Refresh Gallery": "Actualiser la galerie", + "Filter gallery files...": "Filtrer les fichiers de la galerie...", + "Loading files...": "Chargement des fichiers...", + "No files found in this folder.": "Aucun fichier trouvé dans ce dossier.", + "Failed to load gallery from disk.": "Échec du chargement de la galerie depuis le disque.", + "Sort: Newest First": "Trier : Plus récents d'abord", + "Sort: Oldest First": "Trier : Plus anciens d'abord", + "Search Gallery (Filename or Prompt)": "Rechercher dans la galerie (nom de fichier ou prompt)", + "Search filenames or prompt metadata...": "Rechercher des noms de fichiers ou métadonnées de prompt..." } \ No newline at end of file diff --git a/js/locales/fr/settings.json b/js/locales/fr/settings.json index 7373994..65415bb 100644 --- a/js/locales/fr/settings.json +++ b/js/locales/fr/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "Masquer les éléments inutiles", "Default": "Par défaut", "Dock": "Ancrer", - "Hide": "Masquer" + "Hide": "Masquer", + "Enable Output Gallery Sidebar": "Activer la barre latérale de la galerie de sorties" } \ No newline at end of file diff --git a/js/locales/he/main.json b/js/locales/he/main.json index 987db81..e2896d4 100644 --- a/js/locales/he/main.json +++ b/js/locales/he/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "התעלם/כלול צמתים נבחרים בתור", "Renumber All Nodes (Execution Order)": "מספר מחדש את כל הצמתים (סדר ביצוע)", "Set Selected Node ID...": "הגדר מזהה צומת נבחר...", - "Set Node ID...": "הגדר מזהה צומת..." + "Set Node ID...": "הגדר מזהה צומת...", + "Gallery": "גלריה", + "Gallery (Outputs)": "גלריה (תוצרים)", + "Go Up One Level": "עלה שלב אחד למעלה", + "Open Current Folder in File Manager": "פתח תיקייה נוכחית בסייר הקבצים", + "Refresh Gallery": "רענן גלריה", + "Filter gallery files...": "סנן קבצי גלריה...", + "Loading files...": "טוען קבצים...", + "No files found in this folder.": "לא נמצאו קבצים בתיקייה זו.", + "Failed to load gallery from disk.": "טעינת הגלריה מהכונן נכשלה.", + "Sort: Newest First": "מיון: החדשים ביותר קודם", + "Sort: Oldest First": "מיון: הישנים ביותר קודם", + "Search Gallery (Filename or Prompt)": "חיפוש בגלריה (שם קובץ או פרומפט)", + "Search filenames or prompt metadata...": "חיפוש שמות קבצים או מטא-דאטה של פרומפט..." } \ No newline at end of file diff --git a/js/locales/he/settings.json b/js/locales/he/settings.json index 4e9d875..5c62b8a 100644 --- a/js/locales/he/settings.json +++ b/js/locales/he/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "הסתר פריטים מיותרים", "Default": "ברירת מחדל", "Dock": "הצמד", - "Hide": "הסתר" + "Hide": "הסתר", + "Enable Output Gallery Sidebar": "הפעל סרגל צד של גלריית תוצרים" } \ No newline at end of file diff --git a/js/locales/it/main.json b/js/locales/it/main.json index 5d801d8..77b3b15 100644 --- a/js/locales/it/main.json +++ b/js/locales/it/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "Ignora/Attiva nodi selezionati nella coda", "Renumber All Nodes (Execution Order)": "Rinumera tutti i nodi (ordine di esecuzione)", "Set Selected Node ID...": "Imposta ID nodo selezionato...", - "Set Node ID...": "Imposta ID nodo..." + "Set Node ID...": "Imposta ID nodo...", + "Gallery": "Galleria", + "Gallery (Outputs)": "Galleria (Output)", + "Go Up One Level": "Sali di un livello", + "Open Current Folder in File Manager": "Apri cartella corrente in Esplora file", + "Refresh Gallery": "Aggiorna galleria", + "Filter gallery files...": "Filtra file della galleria...", + "Loading files...": "Caricamento file...", + "No files found in this folder.": "Nessun file trovato in questa cartella.", + "Failed to load gallery from disk.": "Impossibile caricare la galleria dal disco.", + "Sort: Newest First": "Ordina: Più recenti prima", + "Sort: Oldest First": "Ordina: Più vecchi prima", + "Search Gallery (Filename or Prompt)": "Cerca nella galleria (nome file o prompt)", + "Search filenames or prompt metadata...": "Cerca nomi di file o metadati del prompt..." } \ No newline at end of file diff --git a/js/locales/it/settings.json b/js/locales/it/settings.json index 587d981..f21ed39 100644 --- a/js/locales/it/settings.json +++ b/js/locales/it/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "Nascondi elementi inutili", "Default": "Predefinito", "Dock": "Aggancia", - "Hide": "Nascondi" + "Hide": "Nascondi", + "Enable Output Gallery Sidebar": "Abilita barra laterale galleria di output" } \ No newline at end of file diff --git a/js/locales/ja/main.json b/js/locales/ja/main.json index 81ee9d2..a0e7539 100644 --- a/js/locales/ja/main.json +++ b/js/locales/ja/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "キューで選択したノードを無視/解除", "Renumber All Nodes (Execution Order)": "すべてのノード番号を再割り当て (実行順)", "Set Selected Node ID...": "選択したノードIDを設定...", - "Set Node ID...": "ノードIDを設定..." + "Set Node ID...": "ノードIDを設定...", + "Gallery": "ギャラリー", + "Gallery (Outputs)": "ギャラリー (出力)", + "Go Up One Level": "1つ上の階層へ", + "Open Current Folder in File Manager": "現在のフォルダーをファイルマネージャーで開く", + "Refresh Gallery": "ギャラリーを更新", + "Filter gallery files...": "ギャラリーファイルを絞り込み...", + "Loading files...": "ファイルを読み込み中...", + "No files found in this folder.": "このフォルダーにはファイルがありません。", + "Failed to load gallery from disk.": "ディスクからギャラリーを読み込めませんでした。", + "Sort: Newest First": "並び替え:新しい順", + "Sort: Oldest First": "並び替え:古い順", + "Search Gallery (Filename or Prompt)": "ギャラリーを検索 (ファイル名またはプロンプト)", + "Search filenames or prompt metadata...": "ファイル名またはプロンプトを検索..." } \ No newline at end of file diff --git a/js/locales/ja/settings.json b/js/locales/ja/settings.json index f27bf37..adc8c59 100644 --- a/js/locales/ja/settings.json +++ b/js/locales/ja/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "不要な項目を非表示", "Default": "デフォルト", "Dock": "ドック", - "Hide": "非表示" + "Hide": "非表示", + "Enable Output Gallery Sidebar": "出力ギャラリーのサイドバーを有効化" } \ No newline at end of file diff --git a/js/locales/ko/main.json b/js/locales/ko/main.json index 6e09601..2e0d9ef 100644 --- a/js/locales/ko/main.json +++ b/js/locales/ko/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "대기열에서 선택한 노드 무시/해제", "Renumber All Nodes (Execution Order)": "모든 노드 재번호 매기기 (실행 순서)", "Set Selected Node ID...": "선택한 노드 ID 설정...", - "Set Node ID...": "노드 ID 설정..." + "Set Node ID...": "노드 ID 설정...", + "Gallery": "갤러리", + "Gallery (Outputs)": "갤러리 (출력)", + "Go Up One Level": "한 수준 위로", + "Open Current Folder in File Manager": "파일 탐색기에서 현재 폴더 열기", + "Refresh Gallery": "갤러리 새로고침", + "Filter gallery files...": "갤러리 파일 필터링...", + "Loading files...": "파일 로딩 중...", + "No files found in this folder.": "이 폴더에 파일이 없습니다.", + "Failed to load gallery from disk.": "디스크에서 갤러리를 불러오지 못했습니다.", + "Sort: Newest First": "정렬: 최신순", + "Sort: Oldest First": "정렬: 오래된순", + "Search Gallery (Filename or Prompt)": "갤러리 검색 (파일명 또는 프롬프트)", + "Search filenames or prompt metadata...": "파일명 또는 프롬프트 메타데이터 검색..." } \ No newline at end of file diff --git a/js/locales/ko/settings.json b/js/locales/ko/settings.json index 58a09d3..48eec38 100644 --- a/js/locales/ko/settings.json +++ b/js/locales/ko/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "불필요한 항목 숨기기", "Default": "기본값", "Dock": "도킹", - "Hide": "숨기기" + "Hide": "숨기기", + "Enable Output Gallery Sidebar": "출력 갤러리 사이드바 활성화" } \ No newline at end of file diff --git a/js/locales/pt-br/main.json b/js/locales/pt-br/main.json index 69b777b..78d0dd2 100644 --- a/js/locales/pt-br/main.json +++ b/js/locales/pt-br/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "Ignorar/Incluir nós selecionados na fila", "Renumber All Nodes (Execution Order)": "Renumerar todos os nós (ordem de execução)", "Set Selected Node ID...": "Definir ID do nó selecionado...", - "Set Node ID...": "Definir ID do nó..." + "Set Node ID...": "Definir ID do nó...", + "Gallery": "Galeria", + "Gallery (Outputs)": "Galeria (Saídas)", + "Go Up One Level": "Subir um nível", + "Open Current Folder in File Manager": "Abrir pasta atual no gerenciador de arquivos", + "Refresh Gallery": "Atualizar galeria", + "Filter gallery files...": "Filtrar arquivos da galeria...", + "Loading files...": "Carregando arquivos...", + "No files found in this folder.": "Nenhum arquivo encontrado nesta pasta.", + "Failed to load gallery from disk.": "Falha ao carregar a galeria do disco.", + "Sort: Newest First": "Ordenar: Mais recentes primeiro", + "Sort: Oldest First": "Ordenar: Mais antigos primeiro", + "Search Gallery (Filename or Prompt)": "Pesquisar na galeria (nome do arquivo ou prompt)", + "Search filenames or prompt metadata...": "Pesquisar nomes de arquivos ou metadados de prompt..." } \ No newline at end of file diff --git a/js/locales/pt-br/settings.json b/js/locales/pt-br/settings.json index b83832f..1b65767 100644 --- a/js/locales/pt-br/settings.json +++ b/js/locales/pt-br/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "Ocultar itens desnecessários", "Default": "Padrão", "Dock": "Fixar", - "Hide": "Ocultar" + "Hide": "Ocultar", + "Enable Output Gallery Sidebar": "Ativar barra lateral da galeria de saídas" } \ No newline at end of file diff --git a/js/locales/ru/main.json b/js/locales/ru/main.json index 003528f..1359dd4 100644 --- a/js/locales/ru/main.json +++ b/js/locales/ru/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "Игнорировать выбранные ноды в очереди", "Renumber All Nodes (Execution Order)": "Перенумеровать ноды (порядок выполнения)", "Set Selected Node ID...": "Задать ID выбранной ноды...", - "Set Node ID...": "Задать ID ноды..." + "Set Node ID...": "Задать ID ноды...", + "Gallery": "Галерея", + "Gallery (Outputs)": "Галерея (Результаты)", + "Go Up One Level": "На один уровень вверх", + "Open Current Folder in File Manager": "Открыть текущую папку в проводнике", + "Refresh Gallery": "Обновить галерею", + "Filter gallery files...": "Фильтр файлов галереи...", + "Loading files...": "Загрузка файлов...", + "No files found in this folder.": "В этой папке нет файлов.", + "Failed to load gallery from disk.": "Не удалось загрузить галерею с диска.", + "Sort: Newest First": "Сортировка: сначала новые", + "Sort: Oldest First": "Сортировка: сначала старые", + "Search Gallery (Filename or Prompt)": "Поиск по галерее (имя файла или промпт)", + "Search filenames or prompt metadata...": "Поиск по именам файлов или промптам..." } \ No newline at end of file diff --git a/js/locales/ru/settings.json b/js/locales/ru/settings.json index c301d07..ea6e261 100644 --- a/js/locales/ru/settings.json +++ b/js/locales/ru/settings.json @@ -29,5 +29,6 @@ "Hide Junk": "Скрыть лишнее", "Default": "По умолчанию", "Dock": "Закрепить", - "Hide": "Скрыть" + "Hide": "Скрыть", + "Enable Output Gallery Sidebar": "Включить боковую панель галереи результатов" } \ No newline at end of file diff --git a/js/locales/tr/main.json b/js/locales/tr/main.json index 2184b0c..f94c7a7 100644 --- a/js/locales/tr/main.json +++ b/js/locales/tr/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "Kuyrukta Seçili Düğümleri Yoksay/Etkinleştir", "Renumber All Nodes (Execution Order)": "Tüm Düğümleri Yeniden Numaralandır (Çalıştırma Sırası)", "Set Selected Node ID...": "Seçili Düğüm Kimliğini Belirle...", - "Set Node ID...": "Düğüm Kimliğini Belirle..." + "Set Node ID...": "Düğüm Kimliğini Belirle...", + "Gallery": "Galeri", + "Gallery (Outputs)": "Galeri (Çıktılar)", + "Go Up One Level": "Bir Düzey Yukarı Çık", + "Open Current Folder in File Manager": "Mevcut Klasörü Dosya Yöneticisinde Aç", + "Refresh Gallery": "Galeriyi Yenile", + "Filter gallery files...": "Galeri dosyalarını filtrele...", + "Loading files...": "Dosyalar yükleniyor...", + "No files found in this folder.": "Bu klasörde dosya bulunamadı.", + "Failed to load gallery from disk.": "Galeri diskten yüklenemedi.", + "Sort: Newest First": "Sıralama: Önce En Yeniler", + "Sort: Oldest First": "Sıralama: Önce En Eskiler", + "Search Gallery (Filename or Prompt)": "Galeride Ara (Dosya Adı veya Prompt)", + "Search filenames or prompt metadata...": "Dosya adlarını veya prompt meta verilerini ara..." } \ No newline at end of file diff --git a/js/locales/tr/settings.json b/js/locales/tr/settings.json index b89c6dc..d9123b9 100644 --- a/js/locales/tr/settings.json +++ b/js/locales/tr/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "Gereksiz Öğeleri Gizle", "Default": "Varsayılan", "Dock": "Sabitle", - "Hide": "Gizle" + "Hide": "Gizle", + "Enable Output Gallery Sidebar": "Çıktı Galerisi Kenar Çubuğunu Etkinleştir" } \ No newline at end of file diff --git a/js/locales/zh-tw/main.json b/js/locales/zh-tw/main.json index ce98e46..8670eb2 100644 --- a/js/locales/zh-tw/main.json +++ b/js/locales/zh-tw/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "在隊列中忽略選取節點", "Renumber All Nodes (Execution Order)": "重新編號所有節點 (按執行順序)", "Set Selected Node ID...": "設定選取節點 ID...", - "Set Node ID...": "設定節點 ID..." + "Set Node ID...": "設定節點 ID...", + "Gallery": "圖庫", + "Gallery (Outputs)": "圖庫 (輸出)", + "Go Up One Level": "返回上一層", + "Open Current Folder in File Manager": "在檔案管理器中開啟目前資料夾", + "Refresh Gallery": "重新整理圖庫", + "Filter gallery files...": "篩選圖庫檔案...", + "Loading files...": "正在載入檔案...", + "No files found in this folder.": "此資料夾中未找到檔案。", + "Failed to load gallery from disk.": "從磁碟載入圖庫失敗。", + "Sort: Newest First": "排序:最新優先", + "Sort: Oldest First": "排序:最舊優先", + "Search Gallery (Filename or Prompt)": "搜尋圖庫 (檔案名稱或提示詞)", + "Search filenames or prompt metadata...": "搜尋檔案名稱或提示詞元資料..." } \ No newline at end of file diff --git a/js/locales/zh-tw/settings.json b/js/locales/zh-tw/settings.json index e03886f..064cd82 100644 --- a/js/locales/zh-tw/settings.json +++ b/js/locales/zh-tw/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "隱藏雜項", "Default": "預設", "Dock": "停靠", - "Hide": "隱藏" + "Hide": "隱藏", + "Enable Output Gallery Sidebar": "啟用輸出圖庫側邊欄" } \ No newline at end of file diff --git a/js/locales/zh/main.json b/js/locales/zh/main.json index 61ed5a6..8274774 100644 --- a/js/locales/zh/main.json +++ b/js/locales/zh/main.json @@ -101,5 +101,18 @@ "Toggle Ignore Selected Node(s) in Queue": "在队列中忽略所选节点", "Renumber All Nodes (Execution Order)": "重新编号所有节点 (按执行顺序)", "Set Selected Node ID...": "设置所选节点 ID...", - "Set Node ID...": "设置节点 ID..." + "Set Node ID...": "设置节点 ID...", + "Gallery": "图库", + "Gallery (Outputs)": "图库 (输出)", + "Go Up One Level": "返回上一级", + "Open Current Folder in File Manager": "在文件管理器中打开当前文件夹", + "Refresh Gallery": "刷新图库", + "Filter gallery files...": "筛选图库文件...", + "Loading files...": "正在加载文件...", + "No files found in this folder.": "此文件夹中未找到文件。", + "Failed to load gallery from disk.": "从磁盘加载图库失败。", + "Sort: Newest First": "排序:最新优先", + "Sort: Oldest First": "排序:最早优先", + "Search Gallery (Filename or Prompt)": "搜索图库 (文件名或提示词)", + "Search filenames or prompt metadata...": "搜索文件名或提示词元数据..." } \ No newline at end of file diff --git a/js/locales/zh/settings.json b/js/locales/zh/settings.json index bbc427d..4f6296e 100644 --- a/js/locales/zh/settings.json +++ b/js/locales/zh/settings.json @@ -28,5 +28,6 @@ "Hide Junk": "隐藏杂项", "Default": "默认", "Dock": "停靠", - "Hide": "隐藏" + "Hide": "隐藏", + "Enable Output Gallery Sidebar": "启用输出图库侧边栏" } \ No newline at end of file diff --git a/js/sidebar.js b/js/sidebar.js index 2b72e26..ba93aef 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -9,6 +9,7 @@ import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, s import { initSessionAndHistory, syncQueue } from "./queue/queueService.js"; import { setupExecutionTracker } from "./queue/executionTracker.js"; import { setupSidebarView, renderSidebar, teardownSidebarView } from "./ui/sidebarView.js"; +import { setupGalleryView, teardownGalleryView } from "./ui/galleryView.js"; import { initI18n, t } from "./core/i18n.js"; import { updateCardProgressTargeted, cardPool } from "./ui/cardRenderer.js"; import { store } from "./core/store.js"; @@ -46,6 +47,32 @@ function registerSettings() { defaultValue: false }); + const setGalleryButtonVisible = (visible) => { + try { + const buttons = document.querySelectorAll(".side-bar-button, [data-tab-id]"); + for (const btn of buttons) { + if (btn.getAttribute("data-tab-id") === "classic-comfy-gallery" || + btn.getAttribute("title")?.includes("Gallery") || + btn.querySelector(".pi-folder")) { + btn.style.display = visible ? "" : "none"; + break; + } + } + } catch (e) { + console.warn("[ComfySidebar] Error toggling gallery tab button:", e); + } + }; + + app.ui.settings.addSetting({ + id: "Comfy Sidebar.Enable Gallery", + name: t("Enable Output Gallery Sidebar"), + type: "boolean", + defaultValue: true, + onChange: (val) => { + setGalleryButtonVisible(val ?? true); + } + }); + app.ui.settings.addSetting({ id: "Comfy Sidebar.Permanent Delete on Disk Deletion", name: t("Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)"), @@ -446,15 +473,65 @@ app.registerExtension({ syncAllNodeBadges(); + let queueScrollPos = 0; + let galleryScrollPos = 0; + let activeSidebarTab = "queue"; + + const getRealScrollEl = (childEl) => { + if (!childEl) return null; + return childEl.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]') + || childEl.parentElement + || childEl; + }; + app.extensionManager.registerSidebarTab({ id: "classic-comfy-sidebar", icon: "pi pi-images", title: t("Queue"), tooltip: `${t("Queue")} (Q)`, type: "custom", - render: (el) => { el.appendChild(sidebarContainer); } + render: (el) => { + document.querySelectorAll(".comfy-sidebar-scroll-top-btn").forEach(btn => { btn.style.display = "none"; }); + const scrollParent = getRealScrollEl(el); + if (activeSidebarTab === "gallery" && scrollParent) { + galleryScrollPos = scrollParent.scrollTop; + } + activeSidebarTab = "queue"; + el.replaceChildren(sidebarContainer); + requestAnimationFrame(() => { + const sp = getRealScrollEl(el); + if (sp) sp.scrollTop = queueScrollPos; + }); + } }); + const galleryContainer = setupGalleryView(); + app.extensionManager.registerSidebarTab({ + id: "classic-comfy-gallery", + icon: "pi pi-folder", + title: t("Gallery"), + tooltip: t("Gallery (Outputs)"), + type: "custom", + render: (el) => { + document.querySelectorAll(".comfy-sidebar-scroll-top-btn").forEach(btn => { btn.style.display = "none"; }); + const scrollParent = getRealScrollEl(el); + if (activeSidebarTab === "queue" && scrollParent) { + queueScrollPos = scrollParent.scrollTop; + } + activeSidebarTab = "gallery"; + el.replaceChildren(galleryContainer); + requestAnimationFrame(() => { + const sp = getRealScrollEl(el); + if (sp) sp.scrollTop = galleryScrollPos; + }); + } + }); + + const isGalleryEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Enable Gallery") ?? true; + if (!isGalleryEnabled) { + setTimeout(() => setGalleryButtonVisible(false), 50); + } + requestAnimationFrame(() => { applySidebarOverride(); }); @@ -466,6 +543,11 @@ app.registerExtension({ } destroyLayoutFix(); teardownSidebarView(); + teardownGalleryView(); + + if (app.extensionManager?.unregisterSidebarTab) { + app.extensionManager.unregisterSidebarTab("classic-comfy-gallery"); + } if (activeKeydownHandler) { document.removeEventListener("keydown", activeKeydownHandler, true); diff --git a/js/ui/galleryView.js b/js/ui/galleryView.js new file mode 100644 index 0000000..c3ab49d --- /dev/null +++ b/js/ui/galleryView.js @@ -0,0 +1,633 @@ +import { app } from "/scripts/app.js"; +import { SettingIds, PromptStatus } from "../core/constants.js"; +import { t } from "../core/i18n.js"; +import { deleteFileOnServer, openFileOrFolder } from "./mediaActions.js"; +import { extractWorkflowFromPng, isImageFormat } from "../utils/utils.js"; +import { renderCardImages } from "./mediaPreview.js"; +import { showNotification } from "../utils/nodeRenumber.js"; + +let galleryContainerEl = null; +let cardStackEl = null; +let scrollToTopBtnEl = null; +let currentColumnCount = 1; +let currentSubfolder = ""; +let currentSearchQuery = ""; + +// Persistent sort order +let sortOrder = "newest"; +try { + sortOrder = localStorage.getItem("ComfySidebar.GallerySortOrder") || "newest"; +} catch (e) {} + +let cachedFolders = []; +let cachedFiles = []; +let displayedFilesCount = 0; +const BATCH_SIZE = 36; +let isLoading = false; + +function formatFileSize(bytes) { + if (!bytes || bytes <= 0) return ""; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} + +function getScrollContainer() { + if (!cardStackEl) return null; + return cardStackEl.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]') + || cardStackEl.parentElement + || cardStackEl; +} + +function updateScrollTopVisibility() { + if (!scrollToTopBtnEl) return; + if (!galleryContainerEl || !galleryContainerEl.isConnected) { + scrollToTopBtnEl.style.display = "none"; + return; + } + const scrollEl = getScrollContainer(); + if (scrollEl) { + scrollToTopBtnEl.style.display = scrollEl.scrollTop > 200 ? "flex" : "none"; + + if (scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 300) { + loadNextBatch(); + } + } +} + +async function fetchGalleryData(subfolder = "", searchQuery = "") { + 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()}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + cachedFolders = data.folders || []; + cachedFiles = data.files || []; + currentSubfolder = (data.subfolder || "").replace(/\\/g, "/"); + applySorting(); + displayedFilesCount = 0; + } catch (e) { + console.error("[ComfySidebar] Failed to load gallery", e); + showNotification(t("Failed to load gallery from disk."), "error"); + } finally { + isLoading = false; + } +} + +function applySorting() { + if (sortOrder === "newest") { + cachedFiles.sort((a, b) => b.mtime - a.mtime); + } else { + cachedFiles.sort((a, b) => a.mtime - b.mtime); + } +} + +function mountGalleryCards(cardStack, folderWrapperEl, cardEls, cols = 1, append = false) { + if (!cardStack) return; + + const oldEmpty = cardStack.querySelector(".comfy-sidebar-gallery-empty-notice"); + if (oldEmpty) oldEmpty.remove(); + + if (!append) { + let subfolderArea = cardStack.querySelector(".comfy-sidebar-subfolders-area"); + if (!subfolderArea) { + subfolderArea = document.createElement("div"); + subfolderArea.className = "comfy-sidebar-subfolders-area"; + cardStack.insertBefore(subfolderArea, cardStack.firstChild); + } + subfolderArea.innerHTML = ""; + if (folderWrapperEl) { + subfolderArea.appendChild(folderWrapperEl); + subfolderArea.style.display = "block"; + } else { + subfolderArea.style.display = "none"; + } + } + + let columnsWrapper = cardStack.querySelector(".comfy-sidebar-masonry-wrapper"); + if (!columnsWrapper) { + columnsWrapper = document.createElement("div"); + columnsWrapper.className = "comfy-sidebar-masonry-wrapper"; + Object.assign(columnsWrapper.style, { display: "flex", gap: "12px", alignItems: "flex-start", width: "100%" }); + cardStack.appendChild(columnsWrapper); + } + + if (!append && cardEls.length === 0 && !folderWrapperEl) { + columnsWrapper.innerHTML = ""; + const emptyEl = document.createElement("div"); + emptyEl.className = "comfy-sidebar-card comfy-sidebar-gallery-empty-notice"; + Object.assign(emptyEl.style, { + padding: "24px", textAlign: "center", color: "var(--desc-color, #888)", fontSize: "12px", width: "100%" + }); + emptyEl.textContent = isLoading ? t("Loading files...") : t("No files found in this folder."); + cardStack.appendChild(emptyEl); + return; + } + + while (columnsWrapper.children.length < cols) { + const colDiv = document.createElement("div"); + colDiv.className = "comfy-sidebar-masonry-col"; + Object.assign(colDiv.style, { flex: "1", display: "flex", flexDirection: "column", gap: "12px", minWidth: "0" }); + columnsWrapper.appendChild(colDiv); + } + while (columnsWrapper.children.length > cols) { + columnsWrapper.removeChild(columnsWrapper.lastChild); + } + + const colHeights = Array.from(columnsWrapper.children).map(c => c.scrollHeight || 0); + + cardEls.forEach((cardEl) => { + let shortestCol = 0; + for (let c = 1; c < cols; c++) { + if (colHeights[c] < colHeights[shortestCol]) shortestCol = c; + } + columnsWrapper.children[shortestCol].appendChild(cardEl); + const cardH = cardEl.offsetHeight > 0 ? cardEl.offsetHeight : 160; + colHeights[shortestCol] += cardH + 12; + }); +} + +function createFileCard(file) { + const card = document.createElement("div"); + card.className = "comfy-sidebar-card completed"; + card.style.position = "relative"; + card.style.cursor = "pointer"; + + const previewUrl = `/view?filename=${encodeURIComponent(file.filename)}&type=output&subfolder=${encodeURIComponent(file.subfolder || "")}`; + const isPng = file.filename.toLowerCase().endsWith(".png"); + + const grid = document.createElement("div"); + grid.style.display = "flex"; + grid.style.flexDirection = "column"; + grid.style.width = "100%"; + + const cardObj = { + element: card, + grid: grid, + dimEl: null, + currentImageIndex: 0, + firstImgElement: null + }; + + const fileAsset = { + filename: file.filename, + subfolder: file.subfolder, + type: "output", + url: previewUrl + }; + + // Render using Queue's exact media pipeline (Images, Videos, 3D, Audio, Files) + renderCardImages(cardObj, { + pid: `gallery-${file.filename}`, + status: PromptStatus.COMPLETED, + images: [fileAsset], + workflow: null + }); + + const sizeBadge = document.createElement("div"); + sizeBadge.className = "comfy-sidebar-card-timer"; + sizeBadge.textContent = formatFileSize(file.size); + sizeBadge.style.display = file.size ? "block" : "none"; + + const isMedia = isImageFormat(file.filename) || /\.(mp4|webm|mov|mkv|avi)$/i.test(file.filename); + let nameBadge = null; + if (isMedia) { + nameBadge = document.createElement("div"); + nameBadge.className = "comfy-sidebar-card-filename-badge"; + nameBadge.textContent = file.filename; + nameBadge.title = file.filename; + } + + const hoverPanel = document.createElement("div"); + hoverPanel.className = "comfy-sidebar-hover-panel"; + Object.assign(hoverPanel.style, { + position: "absolute", bottom: "4px", right: "4px", flexDirection: "column", gap: "4px", zIndex: "20" + }); + + const btnDownload = document.createElement("span"); + btnDownload.className = "pi pi-download comfy-sidebar-card-action-btn"; + btnDownload.title = t("Download Object"); + btnDownload.onclick = (ev) => { + ev.stopPropagation(); + const a = document.createElement("a"); + a.href = previewUrl; + a.download = file.filename; + a.click(); + }; + + let btnJson = null; + if (isPng) { + btnJson = document.createElement("span"); + btnJson.className = "pi pi-file comfy-sidebar-card-action-btn"; + btnJson.title = t("Download JSON"); + btnJson.onclick = async (ev) => { + ev.stopPropagation(); + const wf = await extractWorkflowFromPng(previewUrl); + if (!wf) { + alert(t("No workflow metadata found in this file.")); + return; + } + const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `workflow_${file.filename.replace(/\.[^/.]+$/, "")}.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1500); + }; + } + + const btnDel = document.createElement("span"); + btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del"; + btnDel.title = t("Delete file (Hold Ctrl to delete file from disk)"); + const btnDelLabel = document.createElement("span"); + btnDelLabel.className = "comfy-sidebar-del-label"; + btnDelLabel.textContent = t("Delete File"); + btnDel.appendChild(btnDelLabel); + + let isDeletePending = false, isDiskDelete = false, deleteTimeout = null; + const resetDelete = () => { + isDeletePending = false; + isDiskDelete = false; + btnDel.classList.remove("confirm-delete", "confirm-delete-disk"); + btnDel.title = t("Delete file (Hold Ctrl to delete file from disk)"); + if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; } + }; + + btnDel.onclick = async (ev) => { + ev.stopPropagation(); + const wantsDiskDelete = ev.ctrlKey || ev.metaKey; + + if (!isDeletePending) { + isDeletePending = true; + isDiskDelete = wantsDiskDelete; + btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete"); + btnDel.title = wantsDiskDelete + ? t("Ctrl+Click again to delete file from DISK / TRASH") + : t("Click again to confirm deleting file"); + deleteTimeout = setTimeout(resetDelete, 2000); + } else { + resetDelete(); + const action = await deleteFileOnServer(fileAsset); + if (action) { + const msg = action === "trashed" ? t("Moved file to Recycle Bin.") : t("Permanently deleted file."); + showNotification(msg, "success", t("Delete File")); + cachedFiles = cachedFiles.filter(f => f.filename !== file.filename || f.subfolder !== file.subfolder); + renderGallery(); + } + } + }; + + hoverPanel.appendChild(btnDownload); + if (btnJson) hoverPanel.appendChild(btnJson); + hoverPanel.appendChild(btnDel); + + const leftHoverPanel = document.createElement("div"); + leftHoverPanel.className = "comfy-sidebar-left-hover-panel"; + Object.assign(leftHoverPanel.style, { + position: "absolute", bottom: "4px", left: "4px", flexDirection: "column", gap: "4px", zIndex: "20" + }); + + const isImage = isImageFormat(file.filename); + const btnCopy = document.createElement("span"); + btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; + btnCopy.title = t("Copy to Clipboard"); + if (!isImage) { + btnCopy.style.display = "none"; + } else { + btnCopy.onclick = async (ev) => { + ev.stopPropagation(); + const success = await copyImageToClipboard(previewUrl); + if (success) { + btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn"; + btnCopy.style.color = "#4ade80"; + setTimeout(() => { + btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; + btnCopy.style.color = ""; + }, 1500); + } + }; + } + leftHoverPanel.appendChild(btnCopy); + + card.append(grid, sizeBadge); + if (nameBadge) card.append(nameBadge); + card.append(hoverPanel, leftHoverPanel); + return card; +} + +function updateBreadcrumbUI() { + if (!galleryContainerEl) return; + const breadcrumbBar = galleryContainerEl.querySelector(".comfy-sidebar-breadcrumb-bar"); + if (!breadcrumbBar) return; + + breadcrumbBar.innerHTML = ""; + + const rootBtn = document.createElement("span"); + rootBtn.className = "comfy-sidebar-breadcrumb-item"; + rootBtn.innerHTML = `output`; + rootBtn.onclick = () => navigateToSubfolder(""); + breadcrumbBar.appendChild(rootBtn); + + const normalized = (currentSubfolder || "").replace(/\\/g, "/"); + if (normalized) { + const parts = normalized.split("/").filter(Boolean); + let accumulated = ""; + + parts.forEach((part) => { + accumulated = accumulated ? `${accumulated}/${part}` : part; + const targetPath = accumulated; + + const sep = document.createElement("span"); + sep.className = "comfy-sidebar-breadcrumb-sep"; + sep.textContent = "/"; + breadcrumbBar.appendChild(sep); + + const crumb = document.createElement("span"); + crumb.className = "comfy-sidebar-breadcrumb-item"; + crumb.textContent = part; + crumb.onclick = () => navigateToSubfolder(targetPath); + breadcrumbBar.appendChild(crumb); + }); + } +} + +function loadNextBatch() { + if (displayedFilesCount >= cachedFiles.length) return; + const nextFiles = cachedFiles.slice(displayedFilesCount, displayedFilesCount + BATCH_SIZE); + displayedFilesCount += nextFiles.length; + const cardElements = nextFiles.map(file => createFileCard(file)); + mountGalleryCards(cardStackEl, null, cardElements, currentColumnCount, true); +} + +export function renderGallery() { + if (!galleryContainerEl || !cardStackEl) return; + + updateBreadcrumbUI(); + + let subfolderWrapper = null; + if (cachedFolders.length > 0 && !currentSearchQuery) { + subfolderWrapper = document.createElement("div"); + subfolderWrapper.className = "comfy-sidebar-folders-grid"; + + cachedFolders.forEach(folder => { + const folderCard = document.createElement("div"); + folderCard.className = "comfy-sidebar-folder-card"; + folderCard.title = folder.name; + + const icon = document.createElement("span"); + icon.className = "pi pi-folder comfy-sidebar-folder-icon"; + + const name = document.createElement("span"); + name.className = "comfy-sidebar-folder-name"; + name.textContent = folder.name; + + const ctrlOverlay = document.createElement("div"); + ctrlOverlay.className = "comfy-sidebar-ctrl-overlay"; + ctrlOverlay.innerHTML = `${t("Open Folder")}`; + + folderCard.appendChild(icon); + folderCard.appendChild(name); + folderCard.appendChild(ctrlOverlay); + + folderCard.onclick = async (e) => { + if (e.ctrlKey || e.metaKey) { + e.stopPropagation(); + await openFileOrFolder({ subfolder: folder.subfolder, type: "output" }); + return; + } + await navigateToSubfolder(folder.subfolder); + }; + + subfolderWrapper.appendChild(folderCard); + }); + } + + const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350; + const containerWidth = galleryContainerEl.clientWidth || 0; + if (containerWidth > 0) { + currentColumnCount = Math.max(1, Math.floor(containerWidth / (threshold / 2))); + } + + let columnsWrapper = cardStackEl.querySelector(".comfy-sidebar-masonry-wrapper"); + if (columnsWrapper) columnsWrapper.innerHTML = ""; + + const initialFiles = cachedFiles.slice(0, BATCH_SIZE); + displayedFilesCount = initialFiles.length; + const cardElements = initialFiles.map(file => createFileCard(file)); + + mountGalleryCards(cardStackEl, subfolderWrapper, cardElements, currentColumnCount, false); + updateScrollTopVisibility(); +} + +async function navigateToSubfolder(subfolder) { + currentSubfolder = (subfolder || "").replace(/\\/g, "/"); + currentSearchQuery = ""; + await fetchGalleryData(currentSubfolder); + const scrollEl = getScrollContainer(); + if (scrollEl) scrollEl.scrollTop = 0; + renderGallery(); +} + +export function setupGalleryView() { + const container = document.createElement("div"); + galleryContainerEl = container; + + Object.assign(container.style, { + display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box", + background: "var(--comfy-menu-bg, #121212)", color: "var(--fg-color, #eee)", position: "relative" + }); + + const header = document.createElement("div"); + header.className = "comfy-sidebar-header-root"; + Object.assign(header.style, { position: "relative", marginBottom: "8px", height: "26px", display: "flex", alignItems: "center" }); + + const standardHeader = document.createElement("div"); + Object.assign(standardHeader.style, { display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" }); + + const titleGroup = document.createElement("div"); + Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" }); + + const searchIcon = document.createElement("span"); + searchIcon.className = "pi pi-search comfy-sidebar-icon-btn"; + searchIcon.title = t("Search Gallery (Filename or Prompt)"); + + const title = document.createElement("h3"); + title.textContent = t("Gallery"); + Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" }); + + titleGroup.appendChild(searchIcon); + titleGroup.appendChild(title); + standardHeader.appendChild(titleGroup); + + const actionsGroup = document.createElement("div"); + Object.assign(actionsGroup.style, { display: "flex", gap: "5px", alignItems: "center" }); + + const createHeaderBtn = (iconClass, tooltip, onClickFn) => { + const btn = document.createElement("button"); + btn.className = `${iconClass} comfy-sidebar-header-btn`; + btn.title = tooltip; + btn.onclick = async (ev) => { + ev.stopPropagation(); + btn.style.transform = "scale(0.88)"; + setTimeout(() => { btn.style.transform = ""; }, 120); + await onClickFn(); + }; + return btn; + }; + + const initialSortIcon = sortOrder === "newest" ? "pi-sort-amount-down" : "pi-sort-amount-up"; + const initialSortTitle = sortOrder === "newest" ? t("Sort: Newest First") : t("Sort: Oldest First"); + + const btnSort = createHeaderBtn(`pi ${initialSortIcon}`, initialSortTitle, () => { + sortOrder = sortOrder === "newest" ? "oldest" : "newest"; + try { localStorage.setItem("ComfySidebar.GallerySortOrder", sortOrder); } catch (e) {} + btnSort.className = `pi ${sortOrder === "newest" ? "pi-sort-amount-down" : "pi-sort-amount-up"} comfy-sidebar-header-btn`; + btnSort.title = sortOrder === "newest" ? t("Sort: Newest First") : t("Sort: Oldest First"); + applySorting(); + renderGallery(); + }); + + const btnUp = createHeaderBtn("pi pi-arrow-up", t("Go Up One Level"), async () => { + if (!currentSubfolder) return; + const parts = currentSubfolder.split("/").filter(Boolean); + parts.pop(); + await navigateToSubfolder(parts.join("/")); + }); + + const btnOpenFolder = createHeaderBtn("pi pi-folder-open", t("Open Current Folder in File Manager"), async () => { + await openFileOrFolder({ subfolder: currentSubfolder, type: "output" }); + }); + + const btnRefresh = createHeaderBtn("pi pi-refresh", t("Refresh Gallery"), async () => { + await fetchGalleryData(currentSubfolder, currentSearchQuery); + renderGallery(); + }); + + actionsGroup.append(btnSort, btnUp, btnOpenFolder, btnRefresh); + standardHeader.appendChild(actionsGroup); + + const searchContainer = document.createElement("div"); + Object.assign(searchContainer.style, { + display: "none", width: "100%", alignItems: "center", background: "var(--comfy-input-bg, #181818)", + border: "1px solid var(--border-color, #555)", borderRadius: "4px", padding: "2px 8px", + boxSizing: "border-box", height: "26px" + }); + const sIcon = document.createElement("span"); + sIcon.className = "pi pi-search"; + Object.assign(sIcon.style, { fontSize: "11px", opacity: "0.6", marginRight: "6px", transition: "transform 0.2s" }); + const sInput = document.createElement("input"); + Object.assign(sInput, { type: "text", placeholder: t("Search filenames or prompt metadata...") }); + Object.assign(sInput.style, { + flex: "1", background: "transparent", border: "none", outline: "none", + color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0" + }); + const sClear = document.createElement("span"); + sClear.className = "pi pi-times comfy-sidebar-icon-btn"; + sClear.title = t("Clear & Close Search"); + Object.assign(sClear.style, { marginLeft: "6px" }); + + searchContainer.append(sIcon, sInput, sClear); + header.append(standardHeader, searchContainer); + container.appendChild(header); + + searchIcon.onclick = (e) => { + e.stopPropagation(); + standardHeader.style.display = "none"; + searchContainer.style.display = "flex"; + sInput.focus(); + }; + + let searchDebounce = null; + const closeSearch = () => { + sInput.value = ""; + currentSearchQuery = ""; + sIcon.className = "pi pi-search"; + searchContainer.style.display = "none"; + standardHeader.style.display = "flex"; + fetchGalleryData(currentSubfolder, "").then(() => renderGallery()); + }; + + sClear.onclick = (e) => { e.stopPropagation(); closeSearch(); }; + sInput.onkeydown = (e) => { if (e.key === "Escape") closeSearch(); }; + sInput.oninput = () => { + currentSearchQuery = sInput.value.trim(); + if (searchDebounce) clearTimeout(searchDebounce); + + sIcon.className = "pi pi-spin pi-spinner"; + + searchDebounce = setTimeout(async () => { + try { + await fetchGalleryData(currentSubfolder, currentSearchQuery); + renderGallery(); + } finally { + sIcon.className = "pi pi-search"; + } + }, 350); + }; + + const breadcrumbBar = document.createElement("div"); + breadcrumbBar.className = "comfy-sidebar-breadcrumb-bar"; + container.appendChild(breadcrumbBar); + + const cardStack = document.createElement("div"); + cardStackEl = cardStack; + Object.assign(cardStack.style, { + flex: "1", overflowY: "visible", display: "flex", flexDirection: "column", paddingBottom: "28px" + }); + 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(); + if (scrollEl) scrollEl.scrollTo({ top: 0, behavior: "smooth" }); + }; + 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); + + document.addEventListener("scroll", updateScrollTopVisibility, { capture: true, passive: true }); + + new ResizeObserver((entries) => { + const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350; + const cols = Math.max(1, Math.floor(entries[0].contentRect.width / (threshold / 2))); + if (cols !== currentColumnCount) { + currentColumnCount = cols; + renderGallery(); + } + }).observe(container); + + fetchGalleryData("").then(() => renderGallery()); + + return container; +} + +export function teardownGalleryView() { + document.removeEventListener("scroll", updateScrollTopVisibility, { capture: true, passive: true }); + cachedFolders = []; + cachedFiles = []; + galleryContainerEl = null; + cardStackEl = null; + scrollToTopBtnEl = null; +} \ No newline at end of file diff --git a/js/ui/sidebarView.js b/js/ui/sidebarView.js index a3bfe31..fcd0b0a 100644 --- a/js/ui/sidebarView.js +++ b/js/ui/sidebarView.js @@ -98,7 +98,11 @@ function getScrollContainer() { } function updateScrollTopBtnVisibility() { - if (!scrollToTopBtnEl || !store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) return; + if (!scrollToTopBtnEl) return; + if (!store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) { + scrollToTopBtnEl.style.display = "none"; + return; + } const scrollEl = getScrollContainer(); if (scrollEl) { const viewportEl = scrollEl.parentElement || store.ui.sidebarContainer; diff --git a/js/utils/styles.js b/js/utils/styles.js index e9831f9..2a12919 100644 --- a/js/utils/styles.js +++ b/js/utils/styles.js @@ -584,6 +584,115 @@ export function injectStyles() { border-radius: 3px !important; } + /* Gallery Subfolders & Breadcrumb Styles */ + .comfy-sidebar-breadcrumb-bar { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--desc-color, #aaa); + overflow-x: auto; + white-space: nowrap; + padding: 4px 0 8px 0; + border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); + margin-bottom: 10px; + } + .comfy-sidebar-breadcrumb-item { + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.04); + transition: all 0.15s ease-in-out; + color: var(--fg-color, #eee); + text-decoration: none; + } + .comfy-sidebar-breadcrumb-item:hover { + background: var(--p-primary-color, rgba(59, 130, 246, 0.2)); + color: #fff; + } + .comfy-sidebar-breadcrumb-sep { + opacity: 0.4; + font-size: 10px; + } + + .comfy-sidebar-folders-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 8px; + margin-bottom: 12px; + width: 100%; + } + .comfy-sidebar-folder-card { + background: var(--comfy-input-bg, #181818); + border-radius: 4px; + padding: 8px 10px; + border: 1px solid var(--border-color, #333); + color: var(--comfy-input-color, var(--fg-color, #eee)); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: border-color 0.15s ease, background 0.15s ease, transform 0.1s ease; + user-select: none; + min-width: 0; + } + .comfy-sidebar-folder-card:hover { + border-color: var(--p-primary-color, #0ea5e9) !important; + background: rgba(14, 165, 233, 0.12); + } + .comfy-sidebar-folder-icon { + font-size: 16px; + color: var(--p-primary-color, #0ea5e9); + flex-shrink: 0; + } + body.comfy-sidebar-ctrl-active .comfy-sidebar-folder-card { + cursor: alias !important; + border-color: var(--p-primary-color, #0ea5e9) !important; + } + .comfy-sidebar-folder-name { + font-size: 11px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + + .comfy-sidebar-card-filename-badge { + position: absolute; + top: 6px; + right: 8px; + font-size: 10px; + font-family: monospace; + opacity: 0.7; + background: rgba(0, 0, 0, 0.6); + padding: 2px 4px; + border-radius: 3px; + pointer-events: none; + z-index: 5; + color: #fff; + max-width: 62%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transform: translateZ(0); + } + + /* Folder card layout & Ctrl overlay support */ + .comfy-sidebar-folder-card { + position: relative; + overflow: hidden; + } + body.comfy-sidebar-ctrl-active .comfy-sidebar-folder-card:hover .comfy-sidebar-ctrl-overlay { + display: flex; + } + + /* Comparison selected image */ + .comfy-sidebar-card.comfy-sidebar-comparison-source { + outline: 2px solid #eab308 !important; + box-shadow: 0 0 12px rgba(234, 179, 8, 0.5); + } + /* Smooth animation for the circular loader */ @keyframes comfy-sidebar-spin { from { transform: rotate(0deg); } diff --git a/pyproject.toml b/pyproject.toml index 7f8c8a2..c14d56f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.7.4" +version = "1.8.0" license = { file = "LICENSE" } dependencies = [ "send2trash>=1.8.0",