Compare commits

...
56 Commits
Author SHA1 Message Date
m0rtus59 36ab9e1946 Merge pull request #6 from m0rtus59/chore/cleanup-prompt-search
Clean up leftovers from prompt search change
2026-11-11 07:03:00 +05:00
Claude 2db8592267 Remove unused prompt scan helper and stale test reference 2026-11-10 20:06:51 +00:00
M0rtus59 9cb8f7faba version bump 2026-11-10 16:52:03 +05:00
m0rtus59 a95d2ca1b7 Merge pull request #5 from m0rtus59/fix/queue-prompt-search-sticky-header
Use shared prompt parser for Queue search and fix sticky headers
2026-11-10 16:46:40 +05:00
m0rtus59 f5f5016346 Remove leftover prompt search test file 2026-11-10 16:40:28 +05:00
m0rtus59 e9c7205e25 Match Queue sticky header reveal behavior to Gallery 2026-11-10 16:40:24 +05:00
m0rtus59 6b06b18bca Tidy shared prompt search documentation 2026-11-10 16:17:52 +05:00
m0rtus59 1c6886712a Correct newline expectation in prompt search test 2026-11-10 16:17:27 +05:00
m0rtus59 2286083b9b Bound Queue prompt extraction request sizes 2026-11-10 16:16:56 +05:00
m0rtus59 a23ba4fae2 Test shared Queue prompt extraction helper 2026-11-10 16:16:54 +05:00
m0rtus59 09677772c6 Share graph extraction between Queue and Gallery 2026-11-10 16:16:51 +05:00
m0rtus59 37522e484a Make Queue search header sticky and frosted on scroll 2026-11-10 16:16:06 +05:00
m0rtus59 270616a744 Keep gallery frosted header state while searching 2026-11-10 16:16:03 +05:00
m0rtus59 2941e48879 Populate Queue prompt search text with shared Python parser 2026-11-10 16:15:51 +05:00
m0rtus59 f7436011c4 Search extracted generation prompts in Queue cards 2026-11-10 16:15:33 +05:00
m0rtus59 e783422e7c Persist extracted prompt search text 2026-11-10 16:15:30 +05:00
m0rtus59 41d2e38f2f Add cached prompt search text to card state 2026-11-10 16:15:28 +05:00
m0rtus59 56247cfa8b Retain Queue API prompt graph 2026-11-10 16:15:25 +05:00
m0rtus59 328915a7ee Add Queue prompt extraction endpoint 2026-11-10 16:15:22 +05:00
m0rtus59 543e9e999a Reuse graph parser for Queue search 2026-11-10 16:15:11 +05:00
m0rtus59 421361edb8 Merge pull request #4 from m0rtus59/feat/prompt-only-search
Improve gallery prompt search while retaining filename search
2026-11-10 15:54:54 +05:00
m0rtus59 647c95a5df Revert locale-only search label changes 2026-11-10 15:42:06 +05:00
m0rtus59 7421dfa756 Revert locale-only search label changes 2026-11-10 15:42:03 +05:00
m0rtus59 60883fdf63 Revert locale-only search label changes 2026-11-10 15:41:48 +05:00
m0rtus59 b6143a3abf Revert locale-only search label changes 2026-11-10 15:41:45 +05:00
m0rtus59 828d510891 Revert locale-only search label changes 2026-11-10 15:41:43 +05:00
m0rtus59 024309c050 Revert locale-only search label changes 2026-11-10 15:41:40 +05:00
m0rtus59 2e09cf3963 Revert locale-only search label changes 2026-11-10 15:41:37 +05:00
m0rtus59 ef424be4b1 Revert locale-only search label changes 2026-11-10 15:41:35 +05:00
m0rtus59 a1fa872e2e Revert locale-only search label changes 2026-11-10 15:41:32 +05:00
m0rtus59 d31852e5ab Revert locale-only search label changes 2026-11-10 15:41:26 +05:00
m0rtus59 bb98171982 Revert locale-only search label changes 2026-11-10 15:41:23 +05:00
m0rtus59 004744e8e2 Revert locale-only search label changes 2026-11-10 15:41:21 +05:00
m0rtus59 94933a0246 Revert locale-only search label changes 2026-11-10 15:41:19 +05:00
m0rtus59 c942976c02 Revert locale-only search label changes 2026-11-10 15:41:17 +05:00
m0rtus59 0d19394c8f Document filename and prompt metadata search 2026-11-10 15:41:00 +05:00
m0rtus59 4bca1435a6 Restore filename and prompt search labels 2026-11-10 15:40:57 +05:00
m0rtus59 4eb67f25f0 Keep filename matching alongside prompt search 2026-11-10 15:40:55 +05:00
m0rtus59 40a9cf3db9 Merge pull request #3 from m0rtus59/fix/gallery-teardown-leaks
fix: gallery teardown listener cleanup + idempotent overlay.destroy()
2026-11-10 15:03:12 +05:00
Claude ff0466957e feat(gallery): search only generation prompts; scan off the event loop; cache results
- New prompt_search.py (no ComfyUI imports): reads only the PNG 'prompt' chunk (tEXt/zTXt/iTXt), skips the large 'workflow' chunk, and extracts only text that feeds text-encoder nodes (following links to string/primitive/concat nodes). Model names, sampler names, filenames and other widgets are no longer matched, and filenames are no longer searched.
- Per-file results are cached by (mtime, size); repeat searches only touch new/changed files.
- gallery/list runs in a worker thread so large searches don't block ComfyUI's server; the scan stops when the client disconnects.
- Frontend aborts superseded search requests and ignores stale results.
- UI strings/locales and README updated; unit tests in tests/.
2026-11-10 09:49:31 +00:00
Claude 294e610eb1 fix: clean up gallery key/resize listeners on teardown; guard overlay.destroy()
- galleryView: hoist Ctrl/Meta key handlers to module scope and remove them (plus a new blur reset) in teardownGalleryView(); disconnect the ResizeObserver; clear the ctrl-active body class; reset header-reveal state so a re-initialised gallery starts clean.
- overlay: make destroy() idempotent and make updateOverlayBounds() a no-op afterwards, so a pending rAF/ResizeObserver callback can't re-observe elements after disconnect().
2026-11-10 09:32:57 +00:00
M0rtus59 a33052141a overlay jitter fix 2026-11-10 14:25:34 +05:00
M0rtus59 e2a3c0c596 fixes 2026-11-10 14:18:59 +05:00
m0rtus59 557d910ab4 Merge pull request #2 from m0rtus59/feat/layout-independent-hotkeys
Feat/layout independent hotkeys
2026-11-09 18:27:58 +05:00
Claude be5c8c41c9 i18n: add 'Fix non-ASCII keybinds' setting strings to the remaining locales 2026-11-09 13:20:43 +00:00
Claude 408517273a docs/settings: rename toggle to 'Fix non-ASCII keybinds' and document layout-independent hotkeys in the README 2026-11-09 09:06:39 +00:00
Claude e8b6622919 feat: make hotkeys work on any keyboard layout
ComfyUI matches keybindings on KeyboardEvent.key, which follows the active layout (physical Q is 'й' on a Russian layout). Re-dispatch non-ASCII keys as the US-QWERTY key of the same physical position (event.code) in the window capture phase, before the frontend handler runs.

Skips text fields, IME composition, named keys and layouts that already report ASCII. Toggle: 'Hotkeys work on any keyboard layout' (on by default). en/ru strings added.
2026-11-09 08:51:43 +00:00
Claude 4001848e37 refactor: bind Q through extension keybindings instead of a global keydown handler
Makes the shortcut visible and rebindable in the Keybinding panel. Ctrl+Q and Alt+R keep using the keydown handler for now.
2026-11-09 08:51:43 +00:00
Claude 0733d001f3 fix(i18n): remove duplicate 'Classic Layout' key in ru settings (the later entry already won) 2026-11-09 08:51:43 +00:00
m0rtus59 33878502af Merge pull request #1 from m0rtus59/fix-delete-stale-subfolder
Fix delete-file removing the wrong file on stale subfolder
2026-11-09 02:38:44 +05:00
Claude 2c9ded2cbf Fix delete-file removing the wrong file on stale subfolder
resolve_existing_path fell back to <root>/<filename> when
<root>/<subfolder>/<filename> did not exist, so deleting a file that was
already gone could trash a same-named file in the output root. It also
ignored the requested `type`, so type=temp could resolve into output.

Add a strict mode used only by delete-file: exact subfolder match, no root
fallback, and only the root matching the requested type is considered.
Other callers (open-folder, promote-output) keep the old behavior.
2026-11-08 21:18:42 +00:00
m0rtus59 1a848e2b00 theme consistency fix 2026-11-08 20:34:49 +05:00
m0rtus59 700b85408c DRYing 2026-11-08 20:24:09 +05:00
m0rtus59 6a0b0cd71a wtf, single button keybindings don't work in comfy frontend, did nothing to fix it, just removed some junk 2026-11-08 19:16:44 +05:00
m0rtus59 56452eb855 API optimizations 2026-11-08 18:18:50 +05:00
m0rtus59 81d98f908b Improved themes 2026-11-07 21:56:15 +05:00
40 changed files with 1529 additions and 790 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
__pycache__/
*.pyc
Thumbs.db
.DS_Store
.DS_Store
desktop.ini
+8 -1
View File
@@ -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
View File
@@ -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)
+9 -5
View File
@@ -1,5 +1,6 @@
import { app } from "/scripts/app.js";
import { SettingIds } from "../core/constants.js";
import { getSetting } from "../core/settings.js";
export function findOriginalPropertiesButton() {
const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]');
@@ -71,6 +72,11 @@ export function findPropertiesPanel() {
}
export function isPropertiesPanelOpen() {
const modernOpen = getSetting("Comfy.RightSidePanel.IsOpen");
if (typeof modernOpen === "boolean") {
return modernOpen;
}
const panel = findPropertiesPanel();
if (panel) return true;
@@ -278,11 +284,9 @@ export function applySidebarOverride() {
try {
const overrideStock = (
app.extensionManager?.setting?.get?.(SettingIds.OVERRIDE_STOCK_HISTORY) ??
app.ui?.settings?.getSettingValue?.(SettingIds.OVERRIDE_STOCK_HISTORY) ??
app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ??
app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Override Stock History") ??
false
getSetting(SettingIds.OVERRIDE_STOCK_HISTORY) ??
getSetting("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ??
getSetting("Comfy Sidebar.Override Stock History", false)
);
const stdBtn = findStandardQueueButton();
const ourBtn = findOurSidebarButton();
+40 -81
View File
@@ -11,6 +11,7 @@ import {
} from "./adapter.js";
import { SettingIds } from "../core/constants.js";
import { isRuntimePreviewEnabled, setRuntimePreviewEnabled, setRuntimePreviewStateListener } from "../utils/comparison.js";
import { getSetting, setSetting } from "../core/settings.js";
// Listen for runtime preview state changes to keep button UI in sync
setRuntimePreviewStateListener(() => {
@@ -88,20 +89,11 @@ div:has(> [class*="actionbar"]):not(header):not(.topbar):not(.actionbar-containe
export function syncStockHistoryAndProgressSettings(enable) {
const dockedVal = !!enable;
const progressVal = !enable;
const QPOV2_ID = "Comfy.Queue.QPOV2";
const progressKey = "Comfy.Queue.ShowRunProgressBar";
const QPOV2_ID = SettingIds.DOCK_QUEUE || "Comfy.Queue.QPOV2";
const progressKey = SettingIds.SHOW_PROGRESS_BAR || "Comfy.Queue.ShowRunProgressBar";
if (app.extensionManager?.setting) {
try { app.extensionManager.setting.set(QPOV2_ID, dockedVal); } catch (e) {}
try { app.extensionManager.setting.set(progressKey, progressVal); } catch (e) {}
} else if (app.ui?.settings) {
try { app.ui.settings.setSettingValue(QPOV2_ID, dockedVal); } catch (e) {}
try { app.ui.settings.setSettingValue(progressKey, progressVal); } catch (e) {}
}
try { localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal)); } catch (e) {}
try { localStorage.setItem(progressKey, JSON.stringify(progressVal)); } catch (e) {}
try { window.dispatchEvent(new Event("storage")); } catch (e) {}
setSetting(QPOV2_ID, dockedVal);
setSetting(progressKey, progressVal);
}
let savedButtonData = null;
@@ -128,6 +120,11 @@ function setupSidebarTopObserver() {
}
function isSidebarOnLeft() {
const location = getSetting("Comfy.Sidebar.Location");
if (location === "left" || location === "right") {
return location === "left";
}
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar) return false;
return sidebar.getBoundingClientRect().left < window.innerWidth / 2;
@@ -366,9 +363,8 @@ function setupCanvasMenuPopoverRelocator() {
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar || !popovers.length) return;
const sidebarRect = sidebar.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const onLeft = sidebarRect.left < window.innerWidth / 2;
const onLeft = isSidebarOnLeft();
popovers.forEach((pop) => {
if (pop.style.display !== "none" && !pop.classList.contains("p-overlay-hidden")) {
@@ -425,11 +421,7 @@ export function applyClassicLayout(enable, updateSetting = false) {
if (enable) {
if (updateSetting) {
if (app.extensionManager?.setting) {
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
} else if (app.ui?.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
}
setSetting("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
}
if (!styleEl) {
styleEl = document.createElement("style");
@@ -453,7 +445,7 @@ export function applyClassicLayout(enable, updateSetting = false) {
}
function syncAvatarVisibility() {
const hideAvatar = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Avatar") ?? false;
const hideAvatar = getSetting("Comfy Sidebar.Hide Junk.Avatar", false);
const STYLE_AVATAR_ID = "comfy-sidebar-hide-avatar-override";
let styleEl = document.getElementById(STYLE_AVATAR_ID);
@@ -501,7 +493,7 @@ function updateSidebarTabsVisibility() {
};
Object.entries(tabSelectors).forEach(([tab, selector]) => {
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
const shouldHide = getSetting(`Comfy Sidebar.Hide Junk.${tab}`, false);
const target = sidebar.querySelector(selector);
if (target) {
const tabBtn = target.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || target;
@@ -514,11 +506,9 @@ function updateSidebarTabsVisibility() {
});
const hideStockHistory = (
app.extensionManager?.setting?.get?.(SettingIds.OVERRIDE_STOCK_HISTORY) ??
app.ui?.settings?.getSettingValue?.(SettingIds.OVERRIDE_STOCK_HISTORY) ??
app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ??
app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Override Stock History") ??
false
getSetting(SettingIds.OVERRIDE_STOCK_HISTORY) ??
getSetting("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ??
getSetting("Comfy Sidebar.Override Stock History", false)
);
const historyTarget = sidebar.querySelector('[data-testid="queue-tab-button"], [data-testid="job-history-tab-button"], [class*="lucide--history"]');
if (historyTarget) {
@@ -558,7 +548,7 @@ function cleanHTML(html) {
}
function syncGraphButton() {
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
const hideGraphBtn = getSetting("Comfy Sidebar.Hide Junk.Graph Button", false);
const graphBtn = findGraphButton();
if (graphBtn) {
if (hideGraphBtn) {
@@ -653,11 +643,17 @@ function syncPropertiesButton(isClassicLayoutEnabled) {
customBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const nativeBtn = findOriginalPropertiesButton();
if (nativeBtn) {
nativeBtn.click();
const isOpen = getSetting("Comfy.RightSidePanel.IsOpen");
if (typeof isOpen === "boolean") {
setSetting("Comfy.RightSidePanel.IsOpen", !isOpen);
requestAnimationFrame(() => syncClassicLayout());
} else {
const nativeBtn = findOriginalPropertiesButton();
if (nativeBtn) {
nativeBtn.click();
requestAnimationFrame(() => syncClassicLayout());
}
}
});
@@ -674,7 +670,7 @@ function syncPropertiesButton(isClassicLayoutEnabled) {
}
function syncActiveQueueIndicator(isClassicLayoutEnabled) {
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const hideQueueIndicator = getSetting("Comfy Sidebar.Hide Junk.Override Stock Job History Tab", false);
const indicator = findActiveQueueIndicator();
if (indicator) {
if (isClassicLayoutEnabled && hideQueueIndicator) {
@@ -693,39 +689,9 @@ export function syncRuntimePreviewButton() {
if (!dock) {
dock = document.createElement("div");
dock.className = "comfy-sidebar-runtime-preview-dock";
Object.assign(dock.style, {
position: "sticky",
bottom: "0px",
left: "0px",
width: "100%",
padding: "8px 12px",
boxSizing: "border-box",
background: "var(--comfy-menu-bg, #181818)",
borderTop: "1px solid var(--border-color, rgba(255, 255, 255, 0.12))",
zIndex: "20",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginTop: "auto"
});
const btn = document.createElement("button");
btn.className = "comfy-sidebar-runtime-preview-btn";
Object.assign(btn.style, {
width: "100%",
height: "34px",
borderRadius: "6px",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
fontSize: "12px",
fontWeight: "600",
fontFamily: "sans-serif",
transition: "all 0.15s ease",
outline: "none"
});
btn.onclick = (e) => {
e.preventDefault();
@@ -741,6 +707,7 @@ export function syncRuntimePreviewButton() {
const btn = dock.querySelector(".comfy-sidebar-runtime-preview-btn");
if (btn) {
const active = isRuntimePreviewEnabled();
btn.classList.toggle("active", active);
const lucidePlay = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><polygon points="6 3 20 12 6 21 6 3"/></svg>`;
const lucideEye = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;flex-shrink:0;"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`;
@@ -748,17 +715,9 @@ export function syncRuntimePreviewButton() {
const lucideSquare = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.5;flex-shrink:0;"><rect width="18" height="18" x="3" y="3" rx="2"/></svg>`;
if (active) {
btn.style.background = "rgba(59, 130, 246, 0.18)";
btn.style.border = "1px solid #3b82f6";
btn.style.color = "#93c5fd";
btn.style.boxShadow = "0 0 8px rgba(59, 130, 246, 0.35)";
btn.innerHTML = `<span style="display:flex;align-items:center;gap:7px;">${lucidePlay}<span>Runtime Preview</span></span>${lucideSquareCheck}`;
btn.title = "Runtime Preview is ON (auto-tracking live generation). Click to turn OFF.";
} else {
btn.style.background = "rgba(255, 255, 255, 0.05)";
btn.style.border = "1px solid var(--border-color, rgba(255, 255, 255, 0.15))";
btn.style.color = "var(--desc-color, #aaa)";
btn.style.boxShadow = "none";
btn.innerHTML = `<span style="display:flex;align-items:center;gap:7px;">${lucideEye}<span>Runtime Preview</span></span>${lucideSquare}`;
btn.title = "Click to turn ON Runtime Preview (auto-focus live generation).";
}
@@ -769,24 +728,19 @@ export function syncClassicLayout() {
if (domObserver) domObserver.disconnect();
try {
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
const tabsPos = app.extensionManager?.setting?.get?.("Comfy.Workflow.WorkflowTabsPosition") ??
app.ui?.settings?.getSettingValue("Comfy.Workflow.WorkflowTabsPosition");
const isClassicLayoutEnabled = getSetting("Comfy Sidebar.Comfy Layout", false);
const tabsPos = getSetting("Comfy.Workflow.WorkflowTabsPosition");
// Real-time safeguard: if tabs are in Sidebar, disable Comfy Layout immediately
if (isClassicLayoutEnabled && tabsPos === "Sidebar") {
applyClassicLayout(false, false);
if (app.extensionManager?.setting) {
try { app.extensionManager.setting.set("Comfy Sidebar.Comfy Layout", false); } catch (e) {}
} else if (app.ui?.settings) {
try { app.ui.settings.setSettingValue("Comfy Sidebar.Comfy Layout", false); } catch (e) {}
}
setSetting("Comfy Sidebar.Comfy Layout", false);
return;
}
let canvasControlsMode = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Floating Canvas Controls");
let canvasControlsMode = getSetting("Comfy Sidebar.Hide Junk.Floating Canvas Controls");
if (canvasControlsMode === undefined || canvasControlsMode === null) {
const legacyDock = app.ui?.settings?.getSettingValue("Comfy Sidebar.Dock Canvas Controls");
const legacyDock = getSetting("Comfy Sidebar.Dock Canvas Controls");
canvasControlsMode = legacyDock ? "Dock" : "Default";
} else if (typeof canvasControlsMode === "boolean") {
canvasControlsMode = canvasControlsMode ? "Dock" : "Default";
@@ -961,14 +915,19 @@ export function setupPropertiesPanelToggleFix() {
}
/* Neutralize CanvasModeSelector inner selected backgrounds so idle mode is clean and transparent */
.comfy-sidebar-unfloated-canvas-menu [class*="bg-muted"],
.comfy-sidebar-unfloated-canvas-menu [class*="group-hover:bg-accent"],
.comfy-sidebar-unfloated-canvas-menu > button div,
.comfy-sidebar-unfloated-canvas-menu > div div,
.comfy-sidebar-unfloated-canvas-menu > div button,
.comfy-sidebar-unfloated-canvas-menu > div [role="button"],
.comfy-sidebar-unfloated-canvas-menu [class*="bg-interface-panel-selected-surface"],
.comfy-sidebar-unfloated-canvas-menu [class*="group-hover:bg-interface-button-hover-surface"] {
background: transparent !important;
background-color: transparent !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 0 !important;
}
/* Compact CanvasModeSelector padding and chevrons to eliminate extra sidebar width and ensure full-width highlight */
+1
View File
@@ -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",
+1
View File
@@ -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,
+1
View File
@@ -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,
+27
View File
@@ -0,0 +1,27 @@
import { app } from "/scripts/app.js";
/**
* Safely retrieves a setting value from ComfyUI's extension manager or legacy settings.
*/
export function getSetting(id, fallback = undefined) {
const settings = app.extensionManager?.setting;
if (settings?.get) {
const val = settings.get(id);
if (val !== undefined && val !== null) return val;
}
const legacyVal = app.ui?.settings?.getSettingValue?.(id);
if (legacyVal !== undefined && legacyVal !== null) return legacyVal;
return fallback;
}
/**
* Safely sets a setting value across modern extensionManager and legacy UI settings.
*/
export function setSetting(id, value) {
const settings = app.extensionManager?.setting;
if (settings?.set) {
settings.set(id, value);
return;
}
app.ui?.settings?.setSettingValue?.(id, value);
}
+3 -1
View File
@@ -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 عند تفعيل تخطيط آخر. ولا تتأثر الكتابة في حقول النص."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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 هنگام فعال بودن چیدمان دیگر نیز کار کنند. تایپ در فیلدهای متنی تحت تأثیر قرار نمی‌گیرد."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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 פועלים גם כשפריסה אחרת פעילה. ההקלדה בשדות טקסט אינה מושפעת."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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 などのショートカットが動作するようにします。テキスト入力欄での入力には影響しません。"
}
+3 -1
View File
@@ -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 같은 단축키가 작동합니다. 텍스트 입력란에서의 입력에는 영향을 주지 않습니다."
}
+3 -1
View File
@@ -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."
}
+3 -2
View File
@@ -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 работают при любой активной раскладке. Ввод текста в полях не затрагивается."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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 等快速鍵也能正常運作。在文字欄位中輸入不受影響。"
}
+3 -1
View File
@@ -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 等快捷键也能正常使用。在文本框中输入不受影响。"
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { createPromptState, activatePrompt, updatePromptProgress } from "../core
import { syncQueue, concludeRun, initSessionAndHistory, clearCancelledOrFailed } from "./queueService.js";
import { findImagesInOutputs, findTextsInOutputs } from "../utils/utils.js";
import { updateActiveComparisonPreview, isRuntimePreviewEnabled, focusRuntimePreviewTarget, getActiveComparisonViewer, showFullscreenPreview } from "../utils/comparison.js";
import { getSetting } from "../core/settings.js";
export function setupExecutionTracker(onTargetedProgressUpdate) {
const onStatus = () => syncQueue();
@@ -17,7 +18,7 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
};
const onExecutionStart = (e) => {
const autoClear = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Auto Clear Interrupted") ?? false;
const autoClear = getSetting(SettingIds.AUTO_CLEAR_INTERRUPTED, false);
if (autoClear) {
clearCancelledOrFailed();
}
@@ -70,7 +71,7 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
const onExecuting = (e) => {
const nodeId = e.detail;
const showWorkingNode = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Show Working Node Name") ?? true;
const showWorkingNode = getSetting(SettingIds.SHOW_WORKING_NODE_NAME, true);
const activePid = store.ui.currentlyActivePromptId;
if (activePid && store.hasPrompt(activePid)) {
+2 -1
View File
@@ -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)
};
}
+65 -13
View File
@@ -1,7 +1,8 @@
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { store } from "../core/store.js";
import { PromptStatus, StoreEvents } from "../core/constants.js";
import { PromptStatus, StoreEvents, SettingIds } from "../core/constants.js";
import { getSetting } from "../core/settings.js";
import { createPromptState, activatePrompt, finalizePrompt } from "../core/promptState.js";
import { normalizeQueueItem, normalizeHistoryItem } from "./queueNormalizer.js";
import { findImagesInOutputs, findTextsInOutputs, getPrimaryOutputImages, getNodeTitle } from "../utils/utils.js";
@@ -18,6 +19,54 @@ function extractNodeTitles(outputs, workflow, promptGraph = null) {
return titles;
}
export function extractPromptOutputs(outputs, workflow, promptGraph = null) {
return {
images: getPrimaryOutputImages(outputs, workflow),
texts: findTextsInOutputs(outputs, workflow),
nodeTitles: extractNodeTitles(outputs, workflow, promptGraph)
};
}
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();
@@ -26,29 +75,29 @@ 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);
const activeWorkflow = item.workflow || (app.graph?.serialize?.() || null);
const titles = extractNodeTitles(item.outputs, activeWorkflow);
if (!existing) {
const seq = store.nextSequence();
const images = getPrimaryOutputImages(item.outputs, activeWorkflow);
const texts = findTextsInOutputs(item.outputs, activeWorkflow);
const { images, texts, nodeTitles } = extractPromptOutputs(item.outputs, activeWorkflow);
const newState = createPromptState(item.pid, {
status: PromptStatus.ACTIVE,
images,
texts,
nodeOutputs: item.outputs,
nodeTitles: titles,
nodeTitles,
progress: 0,
progressText: "Processing...",
timestamp: seq,
@@ -57,6 +106,7 @@ export async function syncQueue() {
});
store.setPrompt(item.pid, newState);
} else {
const titles = extractNodeTitles(item.outputs, activeWorkflow);
if (existing.status !== PromptStatus.ACTIVE) {
activatePrompt(existing, { workflow: activeWorkflow, nodeTitles: titles });
store.updatePrompt(item.pid, existing);
@@ -76,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) {
@@ -100,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);
@@ -145,10 +198,10 @@ export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED)
state.status = norm.status;
const promptGraph = hItem[key].prompt?.[2] || null;
const titles = extractNodeTitles(state.nodeOutputs, state.workflow, promptGraph);
await updatePromptSearchTexts([{ pid: key, promptGraph }]);
const { images: primaryImgs, texts: fetchedTexts, nodeTitles: titles } = extractPromptOutputs(norm.outputs, state.workflow, promptGraph);
Object.assign(state.nodeTitles, titles);
const primaryImgs = getPrimaryOutputImages(norm.outputs, state.workflow);
const hasActualSavedOutputs = primaryImgs.some(img => !img.isFallback);
// If cancelled while sampling, do NOT overwrite with older nodes; keep the exact step preview!
@@ -158,7 +211,6 @@ export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED)
}
}
const fetchedTexts = findTextsInOutputs(norm.outputs, state.workflow);
if (fetchedTexts.length > 0) state.texts = fetchedTexts;
break;
}
@@ -173,7 +225,7 @@ export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED)
}
}
const maxItems = app.ui?.settings?.getSettingValue?.("Comfy.Queue.MaxHistoryItems") ?? 64;
const maxItems = getSetting(SettingIds.MAX_HISTORY_ITEMS, 64);
store.pruneHistory(maxItems);
store.updatePrompt(key, state);
syncQueue();
@@ -204,9 +256,7 @@ export async function initSessionAndHistory() {
normalized.sort((a, b) => a.norm.queueNumber - b.norm.queueNumber);
normalized.forEach(({ norm: item, promptGraph }) => {
const images = getPrimaryOutputImages(item.outputs, item.workflow);
const texts = findTextsInOutputs(item.outputs, item.workflow);
const titles = extractNodeTitles(item.outputs, item.workflow, promptGraph);
const { images, texts, nodeTitles: titles } = extractPromptOutputs(item.outputs, item.workflow, promptGraph);
if (images.length === 0 && texts.length === 0 && item.status === PromptStatus.COMPLETED) {
return;
@@ -253,7 +303,9 @@ export async function initSessionAndHistory() {
store.setPrompt(item.pid, newState);
});
const maxItems = app.ui?.settings?.getSettingValue?.("Comfy.Queue.MaxHistoryItems") ?? 64;
await updatePromptSearchTexts(normalized.map(({ norm, promptGraph }) => ({ pid: norm.pid, promptGraph })));
const maxItems = getSetting(SettingIds.MAX_HISTORY_ITEMS, 64);
store.pruneHistory(maxItems);
await syncQueue();
} catch (err) {
+63 -146
View File
@@ -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";
@@ -12,12 +13,42 @@ import { setupSidebarView, renderSidebar, teardownSidebarView } from "./ui/sideb
import { setupGalleryView, teardownGalleryView } from "./ui/galleryView.js";
import { initI18n, t } from "./core/i18n.js";
import { updateCardProgressTargeted, cardPool } from "./ui/cardRenderer.js";
import { getScrollContainer } from "./utils/utils.js";
import { store } from "./core/store.js";
import { getSetting, setSetting } from "./core/settings.js";
let isInitialized = false;
let activeKeydownHandler = null;
let cleanupFns = [];
function setupKeydownShortcuts() {
activeKeydownHandler = (e) => {
const activeEl = document.activeElement;
if (activeEl && (
activeEl.tagName === "INPUT" ||
activeEl.tagName === "TEXTAREA" ||
activeEl.isContentEditable ||
activeEl.tagName === "SELECT"
)) return;
// Ctrl+Q / Cmd+Q: Toggle Ignore Selected Node
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
toggleIgnoreActiveNode(() => renderSidebar());
}
// Alt+R: Renumber all nodes in execution order
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
renumberNodesTopologically();
}
};
document.addEventListener("keydown", activeKeydownHandler, true);
}
function registerSettings() {
app.ui.settings.addSetting({
id: SettingIds.GRID_COLUMNS_THRESHOLD,
@@ -141,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"),
@@ -154,104 +193,6 @@ function registerSettings() {
});
}
function setupSettingsDialogObserver() {
const targetTexts = [
"Grid Columns Threshold",
"Show Pending Count Only",
"Show Working Node Name",
"Auto Clear Interrupted",
"Permanent Delete on Disk Deletion",
"Hide Junk",
"Override Stock History",
"Hide Graph Button",
"Hide Avatar",
"Floating Canvas Controls",
"Classic Layout",
"Comfy Layout",
"Default",
"Dock",
"Hide"
];
const translateDOM = (rootEl) => {
if (!rootEl) return;
const walker = document.createTreeWalker(
rootEl,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const text = node.nodeValue?.trim();
return targetTexts.includes(text) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
}
}
);
const nodesToTranslate = [];
while (walker.nextNode()) {
nodesToTranslate.push(walker.currentNode);
}
for (const textNode of nodesToTranslate) {
const raw = textNode.nodeValue.trim();
const translated = t(raw);
if (translated && translated !== raw) {
textNode.nodeValue = textNode.nodeValue.replace(raw, translated);
}
}
};
const observer = new MutationObserver(() => {
// 1. Translate headers and selected value inside the Settings dialog
const dialog = document.querySelector('.p-dialog, .comfy-settings-dialog, [role="dialog"]');
if (dialog) {
translateDOM(dialog);
}
// 2. Translate dropdown popup menu items (floated directly on document.body)
const overlay = document.querySelector('.p-select-overlay, .p-dropdown-panel, .p-select-list, [data-pc-name="selectoverlay"]');
if (overlay) {
translateDOM(overlay);
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}
function setupKeydownShortcuts() {
activeKeydownHandler = (e) => {
const activeEl = document.activeElement;
if (activeEl && (
activeEl.tagName === "INPUT" ||
activeEl.tagName === "TEXTAREA" ||
activeEl.isContentEditable ||
activeEl.tagName === "SELECT"
)) return;
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
toggleIgnoreActiveNode(() => renderSidebar());
}
// Alt+R: Renumber all nodes in execution order
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
renumberNodesTopologically();
}
};
document.addEventListener("keydown", activeKeydownHandler, true);
}
function setupWorkflowTabsPositionWatcher() {
let isSyncing = false;
const TARGET_SETTING = "Comfy.Workflow.WorkflowTabsPosition";
@@ -259,15 +200,11 @@ function setupWorkflowTabsPositionWatcher() {
const checkAndDisableIfSidebar = (val) => {
if (isSyncing) return;
if (val === "Sidebar") {
const isClassicEnabled = app.ui?.settings?.getSettingValue(SettingIds.CLASSIC_LAYOUT) ?? false;
const isClassicEnabled = getSetting(SettingIds.CLASSIC_LAYOUT, false);
if (isClassicEnabled) {
isSyncing = true;
try {
if (app.extensionManager?.setting) {
try { app.extensionManager.setting.set(SettingIds.CLASSIC_LAYOUT, false); } catch (e) {}
} else if (app.ui?.settings) {
try { app.ui.settings.setSettingValue(SettingIds.CLASSIC_LAYOUT, false); } catch (e) {}
}
setSetting(SettingIds.CLASSIC_LAYOUT, false);
applyClassicLayout(false, false);
} finally {
isSyncing = false;
@@ -278,7 +215,6 @@ function setupWorkflowTabsPositionWatcher() {
const cleanups = [];
// 1. Hook the setting's native onChange callback directly (instant execution on click)
const attachNativeOnChange = (settingObj) => {
if (!settingObj || settingObj._classicWatcherAttached) return;
settingObj._classicWatcherAttached = true;
@@ -293,23 +229,19 @@ function setupWorkflowTabsPositionWatcher() {
});
};
// Check existing registered setting in legacy UI and modern extension manager
attachNativeOnChange(app.ui?.settings?.settings?.[TARGET_SETTING]);
attachNativeOnChange(app.extensionManager?.setting?.getSetting?.(TARGET_SETTING));
attachNativeOnChange(app.extensionManager?.setting?.settings?.[TARGET_SETTING]);
// 2. Hook Vue/Pinia reactive store subscription if available (zero polling, synchronous)
const settingStore = app.extensionManager?.setting?.settingStore;
if (settingStore?.$subscribe) {
const unsubscribe = settingStore.$subscribe(() => {
const val = app.extensionManager?.setting?.get?.(TARGET_SETTING) ??
app.ui?.settings?.getSettingValue(TARGET_SETTING);
const val = getSetting(TARGET_SETTING);
checkAndDisableIfSidebar(val);
});
cleanups.push(unsubscribe);
}
// 3. Hook addSetting in case core registers/re-registers the setting later
if (app.ui?.settings?.addSetting) {
const origAdd = app.ui.settings.addSetting.bind(app.ui.settings);
app.ui.settings.addSetting = (setting) => {
@@ -323,7 +255,6 @@ function setupWorkflowTabsPositionWatcher() {
});
}
// 4. Hook API setters as fallback
if (app.ui?.settings?.setSettingValue) {
const origSetSettingValue = app.ui.settings.setSettingValue.bind(app.ui.settings);
app.ui.settings.setSettingValue = (id, value) => {
@@ -359,15 +290,15 @@ app.registerExtension({
registerSettings();
},
commands: [
// Plain Q toggles the Queue tab through the frontend keybinding system (rebindable in Settings > Keybinding).
keybindings: [
{
id: "ComfySidebar.ToggleSidebar",
label: t("Toggle Comfy Queue Sidebar"),
function: () => {
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
},
combo: { key: "q" },
commandId: "Workspace.ToggleSidebarTab.classic-comfy-sidebar"
}
],
commands: [
{
id: "ComfySidebar.ToggleIgnoreNode",
label: t("Toggle Ignore Selected Node(s) in Queue"),
@@ -423,23 +354,17 @@ app.registerExtension({
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
await initI18n();
const tabsPos = app.extensionManager?.setting?.get?.("Comfy.Workflow.WorkflowTabsPosition") ??
app.ui.settings.getSettingValue("Comfy.Workflow.WorkflowTabsPosition");
let isClassicLayoutEnabled = app.ui.settings.getSettingValue(SettingIds.CLASSIC_LAYOUT) ?? false;
const tabsPos = getSetting("Comfy.Workflow.WorkflowTabsPosition");
let isClassicLayoutEnabled = getSetting(SettingIds.CLASSIC_LAYOUT, false);
// Startup safeguard: if workflows are set to Sidebar, disable Comfy Layout
if (isClassicLayoutEnabled && tabsPos === "Sidebar") {
isClassicLayoutEnabled = false;
if (app.extensionManager?.setting) {
try { app.extensionManager.setting.set(SettingIds.CLASSIC_LAYOUT, false); } catch (e) {}
} else if (app.ui?.settings) {
try { app.ui.settings.setSettingValue(SettingIds.CLASSIC_LAYOUT, false); } catch (e) {}
}
setSetting(SettingIds.CLASSIC_LAYOUT, false);
}
applyClassicLayout(isClassicLayoutEnabled, false);
const isHistoryOverrideEnabled = app.ui.settings.getSettingValue(SettingIds.OVERRIDE_STOCK_HISTORY) ?? false;
const isHistoryOverrideEnabled = getSetting(SettingIds.OVERRIDE_STOCK_HISTORY, false);
if (isHistoryOverrideEnabled) {
syncStockHistoryAndProgressSettings(true);
}
@@ -451,15 +376,14 @@ app.registerExtension({
cleanupFns.push(setupWorkflowTabsPositionWatcher());
cleanupFns.push(setupDragAndDrop());
cleanupFns.push(setupLatinHotkeys());
cleanupFns.push(setupVueNodeObserver());
cleanupFns.push(setupSettingsDialogObserver());
// Setup execution tracker with targeted fast-path update for active card
cleanupFns.push(setupExecutionTracker((pid, progress, nodeName) => {
const cardObj = cardPool.get(pid);
const state = store.getPrompt(pid);
if (cardObj && state) {
const showWorkingNode = app.ui.settings.getSettingValue(SettingIds.SHOW_WORKING_NODE_NAME) ?? true;
const showWorkingNode = getSetting(SettingIds.SHOW_WORKING_NODE_NAME, true);
updateCardProgressTargeted(cardObj, progress, nodeName, showWorkingNode, state);
} else {
renderSidebar();
@@ -477,13 +401,6 @@ app.registerExtension({
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",
@@ -492,14 +409,14 @@ app.registerExtension({
type: "custom",
render: (el) => {
document.querySelectorAll(".comfy-sidebar-scroll-top-btn").forEach(btn => { btn.style.display = "none"; });
const scrollParent = getRealScrollEl(el);
const scrollParent = getScrollContainer(el);
if (activeSidebarTab === "gallery" && scrollParent) {
galleryScrollPos = scrollParent.scrollTop;
}
activeSidebarTab = "queue";
el.replaceChildren(sidebarContainer);
requestAnimationFrame(() => {
const sp = getRealScrollEl(el);
const sp = getScrollContainer(el);
if (sp) sp.scrollTop = queueScrollPos;
});
}
@@ -514,20 +431,20 @@ app.registerExtension({
type: "custom",
render: (el) => {
document.querySelectorAll(".comfy-sidebar-scroll-top-btn").forEach(btn => { btn.style.display = "none"; });
const scrollParent = getRealScrollEl(el);
const scrollParent = getScrollContainer(el);
if (activeSidebarTab === "queue" && scrollParent) {
queueScrollPos = scrollParent.scrollTop;
}
activeSidebarTab = "gallery";
el.replaceChildren(galleryContainer);
requestAnimationFrame(() => {
const sp = getRealScrollEl(el);
const sp = getScrollContainer(el);
if (sp) sp.scrollTop = galleryScrollPos;
});
}
});
const isGalleryEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Enable Gallery") ?? true;
const isGalleryEnabled = getSetting("Comfy Sidebar.Enable Gallery", true);
if (!isGalleryEnabled) {
setTimeout(() => setGalleryButtonVisible(false), 50);
}
@@ -558,4 +475,4 @@ app.registerExtension({
app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar");
}
}
});
});
+14 -43
View File
@@ -5,7 +5,7 @@ import { store } from "../core/store.js";
import { renderCardImages } from "./mediaPreview.js";
import { copyImageToClipboard, openFileOrFolder, deleteFileOnServer, findNodeIdForImage, removeImageFromNodeOutputs } from "./mediaActions.js";
import { centerAndSelectCanvasNode } from "../comfy/adapter.js";
import { getRunOutputs, extractWorkflowFromPng, isImageFormat, animateCardRemoval } from "../utils/utils.js";
import { getRunOutputs, extractWorkflowFromPng, isImageFormat, animateCardRemoval, downloadJson, getScrollContainer, attachDeleteConfirmation } from "../utils/utils.js";
import { showFullscreenPreview } from "../utils/comparison.js";
import { stopAllAudioPlayback } from "./audioController.js";
import { deleteHistoryItem, cancelPendingTask, interruptActive } from "../queue/queueService.js";
@@ -100,13 +100,7 @@ export function syncCardButtonVisibility(cardObj, state) {
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_${state.pid || "comfy"}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1500);
downloadJson(wf, `workflow_${state.pid || "comfy"}.json`);
};
} else {
cardObj.btnJson.style.setProperty("display", "none", "important");
@@ -304,9 +298,7 @@ export function syncCardButtonVisibility(cardObj, state) {
}
}
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|| store.ui.cardStack?.parentElement
|| store.ui.cardStack;
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) {
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
}
@@ -386,18 +378,18 @@ export function getOrCreateCard(state, callbacks = {}) {
const pt = document.createElement("div");
Object.assign(pt.style, {
width: "100%", height: "4px", background: "#333", borderRadius: "2px", marginTop: "8px",
width: "100%", height: "4px", background: "var(--border, #333)", borderRadius: "2px", marginTop: "8px",
overflow: "hidden", display: "none"
});
const pb = document.createElement("div");
Object.assign(pb.style, {
width: "0%", height: "100%", background: "#3b82f6", transition: "width 0.1s linear"
width: "0%", height: "100%", background: "var(--primary, var(--p-primary-color, #ea580c))", transition: "width 0.1s linear"
});
pt.appendChild(pb);
const statusText = document.createElement("div");
Object.assign(statusText.style, {
fontSize: "11px", opacity: "0.9", color: "#3b82f6", textAlign: "center", marginTop: "6px",
fontSize: "11px", opacity: "0.9", color: "var(--primary, var(--p-primary-color, #ea580c))", textAlign: "center", marginTop: "6px",
display: "none", fontWeight: "bold"
});
@@ -503,31 +495,12 @@ export function getOrCreateCard(state, callbacks = {}) {
}
});
let deleteTimeout = null, isDeletePending = false, isDiskDelete = false;
const resetDeleteBtn = () => {
isDeletePending = false;
isDiskDelete = false;
cardObj.btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
cardObj.btnDel.title = t("Delete Card (Ctrl+Click to delete from disk)");
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = (ev.ctrlKey || ev.metaKey) && cardObj.canDeleteFromDisk !== false;
if (!isDeletePending) {
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
cardObj.btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
cardObj.btnDel.title = wantsDiskDelete
? t("Ctrl+Click again to delete file from DISK / TRASH")
: t("Click again to confirm removing card");
deleteTimeout = setTimeout(resetDeleteBtn, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDeleteBtn();
attachDeleteConfirmation(btnDel, {
canDiskDelete: () => cardObj.canDeleteFromDisk !== false,
idleTitle: () => cardObj.canDeleteFromDisk ? t("Delete Card (Hold Ctrl to delete file from disk)") : t("Delete Card"),
confirmTitle: t("Click again to confirm removing card"),
confirmDiskTitle: t("Ctrl+Click again to delete file from DISK / TRASH"),
onConfirm: async (shouldDeleteFromDisk) => {
animateCardRemoval(card, async () => {
const liveState = store.getPrompt(state.pid) || state;
@@ -569,7 +542,7 @@ export function getOrCreateCard(state, callbacks = {}) {
deleteHistoryItem(state.pid);
});
}
};
});
cardPool.set(state.pid, cardObj);
return cardObj;
@@ -645,9 +618,7 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
} else {
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, state, (st) => {
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|| store.ui.cardStack?.parentElement
|| store.ui.cardStack;
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) {
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
}
+93 -83
View File
@@ -2,7 +2,8 @@ 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, animateCardRemoval } from "../utils/utils.js";
import { extractWorkflowFromPng, isImageFormat, animateCardRemoval, getScrollContainer, calculateGridColumns, downloadJson, setupSpotlightTracker, attachDeleteConfirmation } from "../utils/utils.js";
import { getSetting } from "../core/settings.js";
import { renderCardImages } from "./mediaPreview.js";
import { showNotification } from "../utils/nodeRenumber.js";
@@ -16,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";
@@ -37,20 +48,21 @@ function formatFileSize(bytes) {
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 (!galleryContainerEl || !galleryContainerEl.isConnected) {
if (scrollToTopBtnEl) scrollToTopBtnEl.style.display = "none";
return;
}
const scrollEl = getScrollContainer();
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";
}
@@ -67,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;
@@ -113,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 || [];
@@ -129,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() {
@@ -289,47 +321,22 @@ function createFileCard(file) {
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);
downloadJson(wf, `workflow_${file.filename.replace(/\.[^/.]+$/, "")}.json`);
};
}
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del comfy-sidebar-gallery-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();
attachDeleteConfirmation(btnDel, {
idleTitle: t("Delete file (Hold Ctrl to delete file from disk)"),
confirmTitle: t("Click again to confirm deleting file"),
confirmDiskTitle: t("Ctrl+Click again to delete file from DISK / TRASH"),
onConfirm: async () => {
const action = await deleteFileOnServer(fileAsset);
if (action) {
const msg = action === "trashed" ? t("Moved file to Recycle Bin.") : t("Permanently deleted file.");
@@ -345,7 +352,7 @@ function createFileCard(file) {
});
}
}
};
});
hoverPanel.appendChild(btnDownload);
if (btnJson) hoverPanel.appendChild(btnJson);
@@ -394,7 +401,7 @@ function updateBreadcrumbUI() {
const rootBtn = document.createElement("span");
rootBtn.className = "comfy-sidebar-breadcrumb-item";
rootBtn.innerHTML = `<i class="pi pi-home" style="margin-right: 4px; font-size: 11px; color: var(--p-primary-color, #0ea5e9);"></i>output`;
rootBtn.innerHTML = `<i class="pi pi-home" style="margin-right: 4px; font-size: 11px; color: var(--primary, var(--p-primary-color, var(--primary-color, #a855f7)));"></i>output`;
rootBtn.onclick = () => navigateToSubfolder("");
breadcrumbBar.appendChild(rootBtn);
@@ -472,10 +479,10 @@ export function renderGallery() {
});
}
const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350;
const threshold = getSetting(SettingIds.GRID_COLUMNS_THRESHOLD, 350);
const containerWidth = galleryContainerEl.clientWidth || 0;
if (containerWidth > 0) {
currentColumnCount = Math.max(1, Math.floor(containerWidth / (threshold / 2)));
currentColumnCount = calculateGridColumns(containerWidth, threshold);
}
let columnsWrapper = cardStackEl.querySelector(".comfy-sidebar-masonry-wrapper");
@@ -493,7 +500,7 @@ async function navigateToSubfolder(subfolder) {
currentSubfolder = (subfolder || "").replace(/\\/g, "/");
currentSearchQuery = "";
await fetchGalleryData(currentSubfolder);
const scrollEl = getScrollContainer();
const scrollEl = getScrollContainer(cardStackEl);
if (scrollEl) scrollEl.scrollTop = 0;
renderGallery();
}
@@ -580,8 +587,9 @@ export function setupGalleryView() {
const searchContainer = document.createElement("div");
searchContainer.className = "comfy-sidebar-search-container";
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",
display: "none", width: "100%", alignItems: "center",
background: "var(--card, var(--secondary, var(--comfy-input-bg, #181818)))",
border: "1px solid var(--border, var(--border-color, #555))", borderRadius: "4px", padding: "2px 8px",
boxSizing: "border-box", height: "26px"
});
const sIcon = document.createElement("span");
@@ -610,7 +618,7 @@ export function setupGalleryView() {
searchIcon.onclick = (e) => {
e.stopPropagation();
const scrollEl = getScrollContainer();
const scrollEl = getScrollContainer(cardStackEl);
scrollBeforeSearch = scrollEl ? scrollEl.scrollTop : 0;
standardHeader.style.display = "none";
searchContainer.style.display = "flex";
@@ -618,6 +626,7 @@ export function setupGalleryView() {
};
let searchDebounce = null;
let searchSeq = 0;
const closeSearch = () => {
const hadFilter = Boolean(currentSearchQuery || sInput.value.trim());
sInput.value = "";
@@ -631,7 +640,7 @@ export function setupGalleryView() {
if (hadFilter) {
fetchGalleryData(currentSubfolder, "").then(() => {
renderGallery();
const scrollEl = getScrollContainer();
const scrollEl = getScrollContainer(cardStackEl);
if (scrollEl && scrollBeforeSearch > 0) {
scrollEl.scrollTop = scrollBeforeSearch;
requestAnimationFrame(() => { scrollEl.scrollTop = scrollBeforeSearch; });
@@ -648,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);
};
@@ -666,55 +678,38 @@ 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();
const scrollEl = getScrollContainer(cardStackEl);
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);
window.addEventListener("blur", handleCtrlBlur);
// Reveal Spotlight: track cursor position over Gallery card and folder borders
container.addEventListener("pointermove", (e) => {
const card = e.target.closest(".comfy-sidebar-card, .comfy-sidebar-folder-card");
if (card) {
const rect = card.getBoundingClientRect();
card.style.setProperty("--mouse-x", `${e.clientX - rect.left}px`);
card.style.setProperty("--mouse-y", `${e.clientY - rect.top}px`);
}
}, { passive: true });
setupSpotlightTracker(container, ".comfy-sidebar-card, .comfy-sidebar-folder-card");
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)));
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());
@@ -723,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;
}
+4 -4
View File
@@ -1,3 +1,5 @@
import { getSetting } from "../core/settings.js";
export async function copyImageToClipboard(src) {
try {
const res = await fetch(src);
@@ -24,8 +26,6 @@ export async function copyImageToClipboard(src) {
}
}
import { app } from "/scripts/app.js";
export async function openFileOrFolder(img) {
if (!img) return;
@@ -54,7 +54,7 @@ export async function openFileOrFolder(img) {
export async function deleteFileOnServer(fileItem) {
if (!fileItem || !fileItem.filename) return null;
const isPermanent = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Permanent Delete on Disk Deletion") ?? false;
const isPermanent = getSetting("Comfy Sidebar.Permanent Delete on Disk Deletion", false);
try {
const res = await fetch("/comfy-sidebar/delete-file", {
method: "POST",
@@ -124,4 +124,4 @@ export function findNodeIdForImage(state, img) {
}
}
return null;
}
}
+103 -96
View File
@@ -5,7 +5,8 @@ import { t } from "../core/i18n.js";
import { getOrCreateCard, updateCardDOM, resetAllCardHoverStates, cardPool } from "./cardRenderer.js";
import { clearCancelledOrFailed, clearAllHistory, cancelAllPending } from "../queue/queueService.js";
import { updateSidebarBadge, centerAndSelectCanvasNode } from "../comfy/adapter.js";
import { matchesFilter, getRunOutputs, isImageFormat, animateCardRemoval } from "../utils/utils.js";
import { matchesFilter, getRunOutputs, isImageFormat, animateCardRemoval, getScrollContainer, calculateGridColumns, setupSpotlightTracker, attachDeleteConfirmation } from "../utils/utils.js";
import { getSetting } from "../core/settings.js";
import { renderCardImages } from "./mediaPreview.js";
import { deleteFileOnServer, removeImageFromNodeOutputs, copyImageToClipboard, findNodeIdForImage } from "./mediaActions.js";
import { toggleIgnoreActiveNode } from "../comfy/nodes.js";
@@ -16,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;
@@ -90,11 +95,56 @@ function mountCards(cardStack, fullWidthEls, cardEls, cols = 1) {
}
}
function getScrollContainer() {
if (!store.ui.cardStack) return null;
return store.ui.cardStack.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|| store.ui.cardStack.parentElement
|| store.ui.cardStack;
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() {
@@ -103,7 +153,7 @@ function updateScrollTopBtnVisibility() {
scrollToTopBtnEl.style.display = "none";
return;
}
const scrollEl = getScrollContainer();
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) {
const viewportEl = scrollEl.parentElement || store.ui.sidebarContainer;
if (viewportEl && scrollToTopBtnEl.parentNode !== viewportEl) {
@@ -118,6 +168,8 @@ function updateScrollTopBtnVisibility() {
} else {
scrollToTopBtnEl.style.display = "none";
}
handleQueueHeaderScroll(scrollEl);
}
}
@@ -218,8 +270,8 @@ export function setupSidebarView() {
const reset = () => {
isPending = false;
Object.assign(btn.style, {
color: "var(--desc-color, #aaa)", background: "transparent",
borderColor: "var(--border-color, #555)", boxShadow: "none"
color: "", background: "",
borderColor: "", boxShadow: ""
});
if (timeout) { clearTimeout(timeout); timeout = null; }
};
@@ -262,7 +314,7 @@ export function setupSidebarView() {
const divider = document.createElement("div");
Object.assign(divider.style, {
width: "1px", height: "14px", background: "var(--border-color, #444)",
width: "1px", height: "14px", background: "var(--border, var(--border-color, #444))",
margin: "0 2px", opacity: "0.6"
});
@@ -286,8 +338,9 @@ export function setupSidebarView() {
const searchContainer = document.createElement("div");
searchContainer.className = "comfy-sidebar-search-container";
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",
display: "none", width: "100%", alignItems: "center",
background: "var(--card, var(--secondary, var(--comfy-input-bg, #181818)))",
border: "1px solid var(--border, var(--border-color, #555))", borderRadius: "4px", padding: "2px 8px",
boxSizing: "border-box", height: "26px"
});
const searchInputIcon = document.createElement("span");
@@ -310,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();
@@ -341,7 +399,7 @@ export function setupSidebarView() {
overflowY: "visible",
display: "flex",
flexDirection: "column",
paddingBottom: "28px"
paddingBottom: "46px"
});
container.appendChild(cardStack);
@@ -351,27 +409,20 @@ export function setupSidebarView() {
scrollToTopBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
const scrollEl = getScrollContainer();
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) scrollEl.scrollTo({ top: 0, behavior: "smooth" });
});
container.appendChild(scrollToTopBtnEl);
// Reveal Spotlight: track cursor position over Queue card borders
container.addEventListener("pointermove", (e) => {
const card = e.target.closest(".comfy-sidebar-card");
if (card) {
const rect = card.getBoundingClientRect();
card.style.setProperty("--mouse-x", `${e.clientX - rect.left}px`);
card.style.setProperty("--mouse-y", `${e.clientY - rect.top}px`);
}
}, { passive: true });
setupSpotlightTracker(container, ".comfy-sidebar-card");
document.addEventListener("scroll", updateScrollTopBtnVisibility, { 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)));
const threshold = getSetting(SettingIds.GRID_COLUMNS_THRESHOLD, 350);
const cols = calculateGridColumns(entries[0].contentRect.width, threshold);
if (cols !== currentColumnCount) {
currentColumnCount = cols;
renderSidebar();
@@ -410,8 +461,8 @@ export function renderSidebar() {
renderAnimationFrameId = requestAnimationFrame(() => {
if (!store.ui.sidebarContainer || !store.ui.cardStack) return;
const showPendingSummary = app.ui?.settings?.getSettingValue?.(SettingIds.SHOW_PENDING_COUNT_ONLY) ?? true;
const showWorkingNode = app.ui?.settings?.getSettingValue?.(SettingIds.SHOW_WORKING_NODE_NAME) ?? true;
const showPendingSummary = getSetting(SettingIds.SHOW_PENDING_COUNT_ONLY, true);
const showWorkingNode = getSetting(SettingIds.SHOW_WORKING_NODE_NAME, true);
const headerTitle = store.ui.sidebarContainer.querySelector("h3");
const headerSearchIcon = store.ui.sidebarContainer.querySelector(".comfy-sidebar-header-root .pi-search");
@@ -496,37 +547,16 @@ export function renderSidebar() {
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = t("Remove from batch (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("Remove from batch (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;
cardObj.btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
cardObj.btnDel.title = wantsDiskDelete
? t("Ctrl+Click again to delete file from DISK / TRASH")
: t("Click again to confirm removing from batch");
deleteTimeout = setTimeout(resetDelete, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDelete();
attachDeleteConfirmation(btnDel, {
idleTitle: t("Remove from batch (Hold Ctrl to delete file from disk)"),
confirmTitle: t("Click again to confirm removing from batch"),
confirmDiskTitle: t("Ctrl+Click again to delete file from DISK / TRASH"),
onConfirm: async (shouldDeleteFromDisk) => {
animateCardRemoval(card, async () => {
if (shouldDeleteFromDisk && img && img.filename) {
const action = await deleteFileOnServer(img);
@@ -570,7 +600,7 @@ export function renderSidebar() {
renderSidebar();
});
}
};
});
hoverPanel.append(btnImg, btnDel);
@@ -651,7 +681,7 @@ export function renderSidebar() {
mountCards(store.ui.cardStack, [], targetElements, currentColumnCount);
const scrollEl = getScrollContainer();
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) scrollEl.scrollTop = 0;
updateScrollTopBtnVisibility();
return;
@@ -769,37 +799,16 @@ export function renderSidebar() {
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = t("Delete node output (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 node output (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 this node's files from DISK / TRASH")
: t("Click again to confirm removing output");
deleteTimeout = setTimeout(resetDelete, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDelete();
attachDeleteConfirmation(btnDel, {
idleTitle: t("Delete node output (Hold Ctrl to delete file from disk)"),
confirmTitle: t("Click again to confirm removing output"),
confirmDiskTitle: t("Ctrl+Click again to delete this node's files from DISK / TRASH"),
onConfirm: async (shouldDeleteFromDisk) => {
animateCardRemoval(card, async () => {
if (shouldDeleteFromDisk && out.images) {
let count = 0;
@@ -832,7 +841,7 @@ export function renderSidebar() {
renderSidebar();
});
}
};
});
hoverPanel.append(btnImg, btnDel);
@@ -906,7 +915,7 @@ export function renderSidebar() {
nodeOutputs: st.nodeOutputs
}, () => {
// Clicking (1/N) opens batch drill-down for this specific intermediate node
const scrollEl = getScrollContainer();
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) {
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
}
@@ -975,18 +984,15 @@ export function renderSidebar() {
if (!pCard) {
const el = document.createElement("div");
Object.assign(el.style, {
background: "var(--comfy-input-bg, #181818)", border: "2px solid #6c757d", borderRadius: "4px", padding: "10px",
background: "var(--card, var(--secondary, var(--comfy-input-bg, #181818)))", border: "2px solid var(--border, #6c757d)", borderRadius: "4px", padding: "10px",
textAlign: "center", fontSize: "12px", fontWeight: "bold",
color: "var(--desc-color, #aaa)", display: "flex", flexDirection: "column", gap: "8px"
color: "var(--muted-foreground, var(--desc-color, #aaa))", display: "flex", flexDirection: "column", gap: "8px"
});
const textDiv = document.createElement("div");
el.appendChild(textDiv);
const cancelBtn = document.createElement("button");
cancelBtn.className = "comfy-sidebar-cancel-all-btn";
cancelBtn.textContent = t("Cancel All Pending");
Object.assign(cancelBtn.style, {
background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "4px",
cursor: "pointer", fontSize: "11px", fontWeight: "bold"
});
cancelBtn.onclick = async () => {
await cancelAllPending();
};
@@ -1000,11 +1006,8 @@ export function renderSidebar() {
let btnCard = cardPool.get("pending-cancel-all-standalone");
if (!btnCard) {
const btn = document.createElement("button");
btn.className = "comfy-sidebar-cancel-all-btn";
btn.textContent = t("Cancel All Pending");
Object.assign(btn.style, {
background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "6px",
cursor: "pointer", fontSize: "11px", fontWeight: "bold", width: "100%"
});
btn.onclick = async () => {
await cancelAllPending();
};
@@ -1019,10 +1022,10 @@ export function renderSidebar() {
activeTasks.forEach(st => cardElements.push(syncTaskCard(st)));
completedTasks.forEach(st => cardElements.push(syncTaskCard(st)));
const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350;
const threshold = getSetting(SettingIds.GRID_COLUMNS_THRESHOLD, 350);
const containerWidth = store.ui.sidebarContainer?.clientWidth || 0;
if (containerWidth > 0) {
currentColumnCount = Math.max(1, Math.floor(containerWidth / (threshold / 2)));
currentColumnCount = calculateGridColumns(containerWidth, threshold);
}
mountCards(store.ui.cardStack, bannerElements, cardElements, currentColumnCount);
@@ -1030,7 +1033,7 @@ export function renderSidebar() {
if (store.ui.mainQueueScrollTop !== null) {
const restorePos = store.ui.mainQueueScrollTop;
store.ui.mainQueueScrollTop = null;
const scrollEl = getScrollContainer();
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) {
scrollEl.scrollTop = restorePos;
requestAnimationFrame(() => { scrollEl.scrollTop = restorePos; });
@@ -1057,4 +1060,8 @@ export function teardownSidebarView() {
renderAnimationFrameId = null;
}
cardPool.clear();
if (scrollToTopBtnEl && scrollToTopBtnEl.parentNode) {
scrollToTopBtnEl.parentNode.removeChild(scrollToTopBtnEl);
}
scrollToTopBtnEl = null;
}
+28 -15
View File
@@ -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();
@@ -367,11 +371,11 @@ const setupVideoPlayback = (vid, container) => {
Object.assign(controlBar.style, {
position: "absolute", bottom: "16px", left: "50%",
display: "flex", alignItems: "center", gap: "12px",
background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.9))",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 90%, transparent)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px",
fontFamily: "monospace",
color: "var(--fg-color, #eee)",
color: "var(--foreground, var(--fg-color, #eee))",
width: "80%", maxWidth: "500px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)", transform: "translate3d(-50%, 0, 0)",
pointerEvents: "auto"
@@ -398,7 +402,7 @@ const setupVideoPlayback = (vid, container) => {
position: "relative", cursor: "pointer"
});
const scrubberFill = document.createElement("div");
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#3b82f6", borderRadius: "2px" });
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "var(--primary, var(--p-primary-color, #38bdf8))", borderRadius: "2px" });
scrubberContainer.appendChild(scrubberFill);
const timeLabel = document.createElement("span");
@@ -492,12 +496,12 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
Object.assign(header.style, {
position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "16px", zIndex: "30",
color: "var(--desc-color, #aaa)",
color: "var(--muted-foreground, var(--desc-color, #aaa))",
fontSize: "12px",
fontFamily: "sans-serif", pointerEvents: "none",
background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.85))",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(6px)",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 90%, transparent)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(8px)",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)", maxWidth: "85%", textAlign: "center"
});
const infoText = document.createElement("span");
@@ -546,7 +550,7 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
to { transform: rotate(360deg); }
}
</style>
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="animation: comfy-sidebar-spin 1.3s linear infinite; filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.45));">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--primary, var(--p-primary-color, #38bdf8)); animation: comfy-sidebar-spin 1.3s linear infinite; filter: drop-shadow(0 0 12px rgba(168, 85, 247, 0.45));">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
`;
@@ -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);
+126
View File
@@ -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);
};
}
+23 -7
View File
@@ -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;
@@ -126,6 +134,18 @@ export class SidebarOverlay {
const midX = winW / 2;
try {
// 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) {
topOffset = Math.max(topOffset, Math.round(r.bottom));
}
}
const { leftPanels, rightPanels } = findActiveSidebars();
[...leftPanels, ...rightPanels].forEach(p => this.observeElement(p));
@@ -135,9 +155,6 @@ export class SidebarOverlay {
if (rect.right > 0 && rect.right < midX) {
leftOffset = Math.max(leftOffset, Math.round(rect.right));
}
if (rect.top > 0 && rect.top < 120) {
topOffset = Math.max(topOffset, Math.round(rect.top));
}
}
for (const el of rightPanels) {
@@ -145,9 +162,6 @@ export class SidebarOverlay {
if (rect.left > midX && rect.left < winW) {
rightOffset = Math.max(rightOffset, Math.round(winW - rect.left));
}
if (rect.top > 0 && rect.top < 120) {
topOffset = Math.max(topOffset, Math.round(rect.top));
}
}
} catch (e) {
console.error("Comfy Sidebar: Error finding active sidebars:", e);
@@ -174,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)) {
+257 -91
View File
@@ -4,22 +4,22 @@ export function injectStyles() {
style.id = "comfy-sidebar-styles";
style.textContent = `
.comfy-sidebar-card {
background: var(--comfy-input-bg, var(--p-surface-800, rgba(128, 128, 128, 0.08)));
/* Uses the theme's main elevated surface color (matches folder background identically) */
background: var(--card, var(--secondary, var(--p-surface-800, color-mix(in srgb, var(--bg-color, var(--comfy-menu-bg, #181818)) 85%, var(--fg-color, #fff) 15%))));
border-radius: 4px; padding: 8px; position: relative;
min-height: 80px; margin-bottom: 0px; break-inside: avoid;
user-select: none; -webkit-user-select: none;
border: 2px solid var(--border-color, rgba(128, 128, 128, 0.25));
color: var(--comfy-input-color, var(--fg-color, #eee));
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.12)));
color: var(--foreground, var(--comfy-input-color, var(--fg-color, #eee)));
overflow: hidden;
}
/* Subtle neutral border hover (No blue clash with running jobs) */
.comfy-sidebar-card:hover {
border-color: color-mix(in srgb, var(--border-color, #555) 50%, var(--fg-color, #fff) 50%) !important;
background: color-mix(in srgb, var(--comfy-input-bg, var(--p-surface-800, rgba(128, 128, 128, 0.1))) 85%, var(--fg-color, #888) 15%) !important;
border-color: color-mix(in srgb, var(--border, var(--border-color, #555)) 50%, var(--foreground, var(--fg-color, #fff)) 50%) !important;
background: color-mix(in srgb, var(--card, var(--secondary, var(--p-surface-800, var(--comfy-menu-bg, #262626)))) 85%, var(--foreground, var(--fg-color, #fff)) 15%) !important;
transition: border-color 0.2s, background-color 0.2s;
}
/* Border & Frame Spotlight (Renders BEHIND the image so PNG is never washed out) */
/* Border & Frame Spotlight (Uses the theme's main hue) */
.comfy-sidebar-card::before,
.comfy-sidebar-folder-card::before {
content: "";
@@ -28,8 +28,8 @@ export function injectStyles() {
border-radius: inherit;
background: radial-gradient(
160px circle at var(--mouse-x, -999px) var(--mouse-y, -999px),
color-mix(in srgb, var(--p-primary-color, #38bdf8) 30%, var(--fg-color, #fff) 70%),
transparent 75%
color-mix(in srgb, var(--primary, var(--p-primary-color, var(--primary-color, var(--accent, currentColor)))) 60%, transparent),
transparent 70%
);
opacity: 0;
transition: opacity 0.25s ease;
@@ -39,38 +39,20 @@ export function injectStyles() {
.comfy-sidebar-card:hover::before,
.comfy-sidebar-folder-card:hover::before {
opacity: 0.35;
opacity: 0.65;
}
/* Active job uses the theme accent (orange); Cancelled & Error remain standout Yellow & Red */
.comfy-sidebar-card.active { border-color: var(--primary, var(--p-primary-color, #ea580c)) !important; --hover-color: var(--primary, var(--p-primary-color, #ea580c)); }
.comfy-sidebar-card.pending { border-color: #6c757d !important; --hover-color: #adb5bd; }
.comfy-sidebar-card.cancelled { border-color: #ffc107 !important; --hover-color: #ffe082; }
.comfy-sidebar-card.error { border-color: #dc3545 !important; --hover-color: #f87171; }
/* Windows 10 Reveal Spotlight Effect (UI Folder Buttons Only) */
.comfy-sidebar-folder-card {
position: relative;
overflow: hidden;
/* Progress bar track & fill on active cards */
.comfy-sidebar-card.active [class*="progress"] > div,
.comfy-sidebar-card.active [class*="progress-fill"],
.comfy-sidebar-card.active [class*="bar-fill"] {
background: var(--primary, var(--p-primary-color, #ea580c)) !important;
}
.comfy-sidebar-folder-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(
140px circle at var(--mouse-x, -999px) var(--mouse-y, -999px),
rgba(255, 255, 255, 0.16),
transparent 75%
);
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
z-index: 3;
}
.comfy-sidebar-folder-card:hover::before {
opacity: 1;
}
.comfy-sidebar-card.active { --border-color: #3b82f6; --hover-color: #60a5fa; }
.comfy-sidebar-card.pending { --border-color: #6c757d; --hover-color: #adb5bd; }
.comfy-sidebar-card.cancelled { --border-color: #ffc107; --hover-color: #ffe082; }
.comfy-sidebar-card.error { --border-color: #dc3545; --hover-color: #f87171; }
.comfy-sidebar-card.pending .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active .comfy-sidebar-queue-cancel-btn {
@@ -96,25 +78,53 @@ export function injectStyles() {
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 10; color: #fff;
}
/* Card Cancel / Interrupt Button - matches native ComfyUI Topbar Cancel */
.comfy-sidebar-queue-cancel-btn {
display: none;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
background-color: #7f1d1d !important;
color: #e2e8f0 !important;
font-size: 11px !important;
width: 28px !important;
height: 28px !important;
background: var(--destructive, var(--p-button-danger-background, #ef4444)) !important;
color: var(--foreground, var(--fg-color, inherit)) !important;
border: 1px solid var(--destructive-border, transparent) !important;
font-size: 12px !important;
border-radius: 6px !important;
cursor: pointer !important;
transition: background-color 0.15s ease, color 0.15s ease !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
transition: background-color 0.15s ease, filter 0.15s ease !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25) !important;
z-index: 25 !important;
}
.comfy-sidebar-queue-cancel-btn:hover {
background-color: #991b1b !important;
color: #ffffff !important;
background: color-mix(in srgb, var(--destructive, var(--p-button-danger-background, #ef4444)) 88%, var(--foreground, #fff) 12%) !important;
color: var(--foreground, var(--fg-color, inherit)) !important;
filter: brightness(1.06);
}
/* Cancel All Pending Button */
.comfy-sidebar-cancel-all-btn {
background: var(--destructive, var(--p-button-danger-background, #ef4444)) !important;
color: var(--foreground, var(--fg-color, inherit)) !important;
border: 1px solid var(--destructive-border, transparent) !important;
border-radius: 6px !important;
padding: 6px 12px !important;
cursor: pointer !important;
font-size: 11px !important;
font-weight: bold !important;
width: 100% !important;
box-sizing: border-box !important;
transition: background-color 0.15s ease, filter 0.15s ease !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2) !important;
}
.comfy-sidebar-cancel-all-btn:hover {
background: color-mix(in srgb, var(--destructive, var(--p-button-danger-background, #ef4444)) 88%, var(--foreground, #fff) 12%) !important;
filter: brightness(1.06);
}
.comfy-sidebar-cancel-all-btn:active {
filter: brightness(0.95);
}
.comfy-sidebar-hover-panel,
@@ -207,14 +217,14 @@ export function injectStyles() {
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(2px);
background: color-mix(in srgb, var(--card, var(--secondary, #12071f)) 85%, transparent);
backdrop-filter: blur(3px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #38bdf8;
color: var(--primary, var(--p-primary-color, #ea580c));
z-index: 15;
opacity: 0;
pointer-events: none;
@@ -241,10 +251,10 @@ export function injectStyles() {
}
.comfy-sidebar-header-btn {
background: transparent;
color: var(--desc-color, #aaa);
border: 1px solid var(--border-color, #555);
border-radius: 3px;
background: var(--card, var(--secondary, var(--p-surface-800, rgba(255, 255, 255, 0.05))));
color: var(--muted-foreground, var(--desc-color, #aaa));
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)));
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 13px;
@@ -252,19 +262,22 @@ export function injectStyles() {
}
.comfy-sidebar-header-btn:hover {
border-color: var(--fg-color, #eee);
color: var(--fg-color, #eee);
border-color: color-mix(in srgb, var(--border, var(--border-color, #a855f7)) 65%, var(--foreground, #fff) 35%);
color: var(--foreground, #fff);
background: color-mix(in srgb, var(--card, var(--secondary, rgba(255, 255, 255, 0.12))) 65%, var(--border, var(--border-color, #a855f7)) 35%);
}
.comfy-sidebar-icon-btn {
cursor: pointer;
font-size: 13px;
opacity: 0.6;
transition: opacity 0.15s ease-in-out;
color: var(--foreground, var(--fg-color, inherit));
transition: opacity 0.15s ease-in-out, color 0.15s ease-in-out;
}
.comfy-sidebar-icon-btn:hover {
opacity: 1;
color: var(--primary, var(--p-primary-color, inherit));
}
.comfy-sidebar-scroll-top-btn {
@@ -274,25 +287,25 @@ export function injectStyles() {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(30, 30, 30, 0.9);
color: #eee;
border: 1px solid var(--border-color, #555);
background: var(--card, var(--secondary, rgba(30, 30, 30, 0.9)));
color: var(--foreground, #eee);
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)));
cursor: pointer;
font-size: 13px;
display: none;
align-items: center;
justify-content: center;
z-index: 999;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
transition: all 0.15s ease-in-out;
opacity: 0.85;
}
.comfy-sidebar-scroll-top-btn:hover {
opacity: 1;
background: var(--p-primary-color, #3b82f6);
background: var(--primary, var(--p-primary-color, #ea580c));
color: #fff;
border-color: var(--p-primary-color, #3b82f6);
border-color: var(--primary, var(--p-primary-color, #ea580c));
}
/* 3D Preview Cards */
@@ -311,8 +324,8 @@ export function injectStyles() {
}
.comfy-sidebar-3d-wrapper:hover {
border-color: #3b82f6;
box-shadow: 0 0 12px rgba(59, 130, 246, 0.35);
border-color: var(--primary, var(--p-primary-color, #ea580c));
box-shadow: 0 0 12px color-mix(in srgb, var(--primary, #ea580c) 35%, transparent);
}
.comfy-sidebar-3d-preview-img {
@@ -506,13 +519,13 @@ export function injectStyles() {
}
.comfy-sidebar-file-wrapper:hover {
border-color: #0ea5e9;
box-shadow: 0 0 14px rgba(14, 165, 233, 0.35);
border-color: var(--primary, var(--p-primary-color, #ea580c));
box-shadow: 0 0 14px color-mix(in srgb, var(--primary, #ea580c) 35%, transparent);
transform: translateY(-1px);
}
.comfy-sidebar-file-badge {
background: rgba(14, 165, 233, 0.9);
background: var(--primary, var(--p-primary-color, #ea580c));
color: #fff;
font-size: 9px;
font-weight: bold;
@@ -566,7 +579,7 @@ export function injectStyles() {
.comfy-sidebar-text-clamp {
font-size: 11px;
line-height: 1.45;
color: #cbd5e1;
color: var(--foreground, #cbd5e1);
max-height: 84px;
overflow: hidden;
display: -webkit-box;
@@ -574,17 +587,93 @@ export function injectStyles() {
-webkit-box-orient: vertical;
cursor: pointer;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.04);
/* Solid theme surface that sits above the spotlight, keeping text crisp and readable */
background: var(--card, var(--secondary, var(--p-surface-800, #181818)));
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.12)));
margin-top: 18px;
word-break: break-word;
transition: all 0.15s ease-in-out;
position: relative;
z-index: 2;
}
.comfy-sidebar-text-clamp:hover {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.08);
/* Follows the theme accent (orange) */
border-color: var(--primary, var(--p-primary-color, #ea580c));
background: color-mix(in srgb, var(--primary, var(--p-primary-color, #ea580c)) 6%, var(--card, var(--secondary, #181818)) 94%);
}
/* Translucent theme backdrop behind Text Reader & Fullscreen Previews */
.comfy-sidebar-comparison-overlay,
.comfy-sidebar-overlay,
body.comfy-sidebar-overlay-active > div[style*="position: fixed"],
body.comfy-sidebar-overlay-active > div[style*="position:fixed"] {
background: color-mix(in srgb, var(--background, var(--card, var(--comfy-menu-bg, #13091f))) 82%, transparent) !important;
backdrop-filter: blur(10px) !important;
-webkit-backdrop-filter: blur(10px) !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 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: var(--primary, var(--p-primary-color, #ea580c)) !important;
}
body.comfy-sidebar-overlay-active [style*="50%"] svg,
body.comfy-sidebar-overlay-active [style*="50%"] i,
body.comfy-sidebar-overlay-active [style*="50%"] line,
body.comfy-sidebar-overlay-active [style*="50%"] path {
stroke: currentColor !important;
color: inherit !important;
}
/* Elevate outer sidebar dock containers above the preview overlay */
@@ -607,8 +696,9 @@ export function injectStyles() {
z-index: 1005 !important;
}
/* Media wrapper sits on z-index 2 so the spotlight shines on the border behind it */
.comfy-sidebar-card .comfy-sidebar-media-wrapper {
/* Media and text wrappers sit on z-index 2 so the spotlight shines on the border behind them */
.comfy-sidebar-card .comfy-sidebar-media-wrapper,
.comfy-sidebar-card .comfy-sidebar-text-clamp {
z-index: 2 !important;
position: relative;
}
@@ -622,6 +712,20 @@ export function injectStyles() {
z-index: 30 !important;
}
/* Sidebar Tab Counter Badge (Queue count) */
.side-tool-bar-container [class*="badge"],
.comfyui-sidebar [class*="badge"],
nav [class*="badge"],
aside [class*="badge"],
.side-tool-bar-end [class*="badge"],
button:has(.pi-images) + span,
button:has([class*="queue"]) + span {
background-color: var(--primary, var(--p-primary-color, #ea580c)) !important;
background: var(--primary, var(--p-primary-color, #ea580c)) !important;
color: var(--primary-foreground, #ffffff) !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4) !important;
}
/* Slim down the Queue sidebar scrollbar so it stops colliding with the separator */
.comfyui-sidebar ::-webkit-scrollbar,
.comfy-sidebar ::-webkit-scrollbar,
@@ -660,10 +764,11 @@ export function injectStyles() {
}
.comfy-sidebar-sticky-header-group.is-scrolled {
background: color-mix(in srgb, var(--p-content-background, var(--bg-color, var(--comfy-menu-bg, #181818))) 65%, transparent);
background: color-mix(in srgb, var(--card, var(--secondary, var(--background, var(--bg-color, #181818)))) 75%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
border-bottom: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.08)));
}
/* Gallery Subfolders & Breadcrumb Styles */
@@ -672,24 +777,26 @@ export function injectStyles() {
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--desc-color, #aaa);
color: var(--muted-foreground, 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));
border-bottom: 1px solid var(--border, 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);
background: var(--card, var(--secondary, var(--p-surface-800, rgba(255, 255, 255, 0.05))));
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.08)));
transition: all 0.15s ease-in-out;
color: var(--fg-color, #eee);
color: var(--foreground, var(--fg-color, #eee));
text-decoration: none;
}
.comfy-sidebar-breadcrumb-item:hover {
background: var(--p-primary-color, rgba(59, 130, 246, 0.2));
background: color-mix(in srgb, var(--primary, var(--p-primary-color, #38bdf8)) 25%, transparent);
border-color: var(--primary, var(--p-primary-color, #38bdf8));
color: #fff;
}
.comfy-sidebar-breadcrumb-sep {
@@ -705,35 +812,37 @@ export function injectStyles() {
width: 100%;
}
.comfy-sidebar-folder-card {
background: var(--comfy-input-bg, var(--p-surface-800, rgba(128, 128, 128, 0.08)));
/* Uses the theme's main elevated surface color */
background: var(--card, var(--secondary, var(--p-surface-800, color-mix(in srgb, var(--bg-color, var(--comfy-menu-bg, #181818)) 85%, var(--fg-color, #fff) 15%))));
border-radius: 4px;
padding: 8px 10px;
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.25));
color: var(--comfy-input-color, var(--fg-color, #eee));
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.12)));
color: var(--foreground, 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;
transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.1s ease;
user-select: none;
min-width: 0;
position: relative;
overflow: hidden;
}
.comfy-sidebar-folder-card:hover {
border-color: color-mix(in srgb, var(--border-color, #555) 50%, var(--fg-color, #fff) 50%);
background: color-mix(in srgb, var(--comfy-input-bg, var(--p-surface-800, rgba(128, 128, 128, 0.1))) 85%, var(--fg-color, #888) 15%);
border-color: color-mix(in srgb, var(--border, var(--border-color, #555)) 50%, var(--foreground, var(--fg-color, #fff)) 50%);
background: color-mix(in srgb, var(--card, var(--secondary, var(--p-surface-800, var(--comfy-menu-bg, #262626)))) 85%, var(--foreground, var(--fg-color, #fff)) 15%);
}
.comfy-sidebar-folder-icon {
font-size: 16px;
color: var(--p-primary-color, #0ea5e9);
/* Keeps the accent color (orange, blue, or purple) strictly for the icon */
color: var(--primary, var(--p-primary-color, var(--primary-color, #38bdf8)));
flex-shrink: 0;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-folder-card:hover {
cursor: alias !important;
border-color: var(--p-primary-color, #0ea5e9) !important;
box-shadow: 0 0 0 1px var(--p-primary-color, #0ea5e9), 0 0 10px rgba(14, 165, 233, 0.3) !important;
background: rgba(14, 165, 233, 0.15) !important;
border-color: var(--primary, var(--p-primary-color, var(--primary-color, #a855f7))) !important;
box-shadow: 0 0 0 1px var(--primary, var(--p-primary-color, var(--primary-color, #a855f7))), 0 0 10px rgba(168, 85, 247, 0.3) !important;
background: color-mix(in srgb, var(--primary, var(--p-primary-color, var(--primary-color, #a855f7))) 15%, transparent) !important;
}
.comfy-sidebar-folder-name {
font-size: 11px;
@@ -784,6 +893,63 @@ export function injectStyles() {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Runtime Preview Dock in Properties Panel */
.comfy-sidebar-runtime-preview-dock {
position: sticky;
bottom: 0px;
left: 0px;
width: 100%;
padding: 8px 12px;
box-sizing: border-box;
background: color-mix(in srgb, var(--card, var(--bg-color, var(--comfy-menu-bg, #181818))) 85%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.12)));
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
margin-top: auto;
}
.comfy-sidebar-runtime-preview-btn {
width: 100%;
height: 34px;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
transition: all 0.15s ease-in-out;
outline: none;
box-sizing: border-box;
background: var(--card, var(--secondary, rgba(255, 255, 255, 0.05)));
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)));
color: var(--muted-foreground, var(--desc-color, #aaa));
box-shadow: none;
}
.comfy-sidebar-runtime-preview-btn:hover {
border-color: color-mix(in srgb, var(--primary, var(--p-primary-color, #ea580c)) 65%, var(--border, #666) 35%);
color: var(--foreground, #fff);
background: color-mix(in srgb, var(--primary, var(--p-primary-color, #ea580c)) 8%, var(--card, rgba(255, 255, 255, 0.08)) 92%);
}
.comfy-sidebar-runtime-preview-btn.active {
background: color-mix(in srgb, var(--primary, var(--p-primary-color, var(--primary-color, #ea580c))) 18%, transparent) !important;
border: 1px solid var(--primary, var(--p-primary-color, var(--primary-color, #ea580c))) !important;
color: var(--primary, var(--p-primary-color, var(--primary-color, #ea580c))) !important;
box-shadow: 0 0 10px color-mix(in srgb, var(--primary, var(--p-primary-color, var(--primary-color, #ea580c))) 35%, transparent) !important;
}
.comfy-sidebar-runtime-preview-btn.active:hover {
filter: brightness(1.1);
}
`;
document.head.appendChild(style);
}
+30 -20
View File
@@ -40,7 +40,7 @@ function parseInlineMarkdown(html) {
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
html = html.replace(/~~(.*?)~~/g, '<span style="text-decoration:line-through;color:#888;">$1</span>');
html = html.replace(/\[([^\]]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#61afef;text-decoration:none;border-bottom:1px dashed #61afef;cursor:pointer;">$1</a>');
html = html.replace(/\[([^\]]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:var(--primary, #ea580c);text-decoration:none;border-bottom:1px dashed var(--primary, #ea580c);cursor:pointer;">$1</a>');
html = html.replace(/\$(?!\s)([^\$]+?)(?<!\s)\$/g, '<span style="font-family:\'Times New Roman\',Times,serif;font-size:14px;color:#e5c07b;font-style:italic;padding:0 2px;">$1</span>');
return html;
}
@@ -91,8 +91,8 @@ function parseMarkdown(text) {
if (inCodeBlock) {
const rCode = codeContent.join("\n");
const langBadge = codeLang ? `<div style="position:absolute;top:6px;left:10px;color:#85c5ec;font-size:10px;font-family:sans-serif;font-weight:bold;text-transform:uppercase;opacity:0.8;user-select:none;">${codeLang}</div>` : '';
const btnStyle = "position:absolute;top:6px;right:6px;background:#333;color:#aaa;border:1px solid #555;border-radius:3px;padding:3px 8px;font-size:10px;cursor:pointer;";
htmlLines.push(`<div class="code-block-container" style="position:relative;margin:8px 0;">${langBadge}<button onclick="window.moonCopyCode(this)" style="${btnStyle}">${t("Copy")}</button><pre style="background:#1e1e1e;padding:10px;padding-top:28px;border-radius:4px;border:1px solid #333;font-family:monospace;overflow-x:auto;margin:0;color:#85c5ec;"><code>${rCode}</code></pre></div>`);
const btnStyle = "position:absolute;top:6px;right:6px;background:var(--secondary, #333);color:var(--muted-foreground, #aaa);border:1px solid var(--border, #555);border-radius:3px;padding:3px 8px;font-size:10px;cursor:pointer;";
htmlLines.push(`<div class="code-block-container" style="position:relative;margin:8px 0;">${langBadge}<button onclick="window.moonCopyCode(this)" style="${btnStyle}">${t("Copy")}</button><pre style="background:color-mix(in srgb, var(--card, #1e1e1e) 90%, black 10%);padding:10px;padding-top:28px;border-radius:4px;border:1px solid var(--border, #333);font-family:monospace;overflow-x:auto;margin:0;color:#85c5ec;"><code>${rCode}</code></pre></div>`);
codeContent = []; codeLang = ""; inCodeBlock = false;
} else {
inCodeBlock = true; codeLang = trimmed.slice(3).trim();
@@ -202,14 +202,14 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
const readerBox = document.createElement("div");
Object.assign(readerBox.style, {
background: "var(--comfy-menu-bg, rgba(18, 18, 24, 0.95))",
backdropFilter: "blur(12px)",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
background: "color-mix(in srgb, var(--card, var(--secondary, var(--p-surface-800, #181818))) 94%, var(--background, #000) 6%)",
backdropFilter: "blur(16px)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
borderRadius: "12px",
padding: "20px 24px", width: "85%", maxWidth: "840px", height: "80vh",
boxShadow: "0 12px 40px rgba(0,0,0,0.4)", display: "flex",
boxShadow: "0 12px 40px rgba(0,0,0,0.5)", display: "flex",
flexDirection: "column", gap: "14px", zIndex: "20",
color: "var(--fg-color, #eee)",
color: "var(--foreground, var(--fg-color, #eee))",
pointerEvents: "auto"
});
@@ -222,7 +222,7 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
const headerRow = document.createElement("div");
Object.assign(headerRow.style, {
display: "flex", alignItems: "center", justifyContent: "space-between",
borderBottom: "1px solid rgba(255,255,255,0.1)", paddingBottom: "12px",
borderBottom: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.12)))", paddingBottom: "12px",
flexWrap: "wrap", gap: "10px"
});
@@ -242,7 +242,9 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
const statsBadge = document.createElement("span");
Object.assign(statsBadge.style, {
fontSize: "10px", color: "#94a3b8", background: "rgba(255,255,255,0.06)",
fontSize: "10px", color: "var(--muted-foreground, #94a3b8)",
background: "var(--secondary, rgba(255,255,255,0.06))",
border: "1px solid var(--border, transparent)",
padding: "3px 8px", borderRadius: "4px", fontFamily: "monospace",
whiteSpace: "nowrap", flexShrink: "0"
});
@@ -254,7 +256,9 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
const modeBtn = document.createElement("button");
Object.assign(modeBtn.style, {
background: "#334155", border: "1px solid #64748b", color: "#f8fafc",
background: "var(--primary, var(--p-primary-color, #a855f7))",
border: "1px solid var(--primary, var(--p-primary-color, #a855f7))",
color: "#ffffff",
borderRadius: "6px", padding: "5px 12px", fontSize: "11px", cursor: "pointer",
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
whiteSpace: "nowrap", minWidth: "96px", height: "28px", boxSizing: "border-box"
@@ -263,7 +267,9 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
const copyBtn = document.createElement("button");
Object.assign(copyBtn.style, {
background: "#1e293b", border: "1px solid #475569", color: "#e2e8f0",
background: "var(--secondary, var(--p-surface-800, #1e293b))",
border: "1px solid var(--border, var(--border-color, #475569))",
color: "var(--foreground, #e2e8f0)",
borderRadius: "6px", padding: "5px 10px", fontSize: "11px", cursor: "pointer",
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
whiteSpace: "nowrap", height: "28px", boxSizing: "border-box"
@@ -272,7 +278,9 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
const downloadBtn = document.createElement("button");
Object.assign(downloadBtn.style, {
background: "#1e293b", border: "1px solid #475569", color: "#e2e8f0",
background: "var(--secondary, var(--p-surface-800, #1e293b))",
border: "1px solid var(--border, var(--border-color, #475569))",
color: "var(--foreground, #e2e8f0)",
borderRadius: "6px", padding: "5px 10px", fontSize: "11px", cursor: "pointer",
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
whiteSpace: "nowrap", height: "28px", boxSizing: "border-box"
@@ -286,12 +294,12 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
Object.assign(textArea.style, {
flex: "1", overflowY: "auto", fontSize: "13px",
lineHeight: "1.6",
color: "var(--fg-color, #e2e8f0)",
color: "var(--foreground, var(--fg-color, #e2e8f0))",
wordBreak: "break-word",
userSelect: "text", "-webkit-user-select": "text",
background: "var(--comfy-input-bg, rgba(0, 0, 0, 0.2))",
background: "color-mix(in srgb, var(--background, var(--comfy-input-bg, #000)) 75%, transparent)",
padding: "16px 20px", borderRadius: "6px",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.08))",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.1)))",
scrollbarWidth: "thin"
});
@@ -301,15 +309,17 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
if (isMarkdownMode) {
modeBtn.innerHTML = `<i class="pi pi-code"></i><span>Markdown</span>`;
modeBtn.style.background = "#3b82f6";
modeBtn.style.borderColor = "#60a5fa";
modeBtn.style.background = "var(--primary, var(--p-primary-color, #a855f7))";
modeBtn.style.borderColor = "var(--primary, var(--p-primary-color, #a855f7))";
modeBtn.style.color = "#ffffff";
textArea.style.fontFamily = "sans-serif";
textArea.style.whiteSpace = "normal";
textArea.innerHTML = parseMarkdown(rawText);
} else {
modeBtn.innerHTML = `<i class="pi pi-align-left"></i><span>${t("Raw Text")}</span>`;
modeBtn.style.background = "#334155";
modeBtn.style.borderColor = "#64748b";
modeBtn.style.background = "var(--secondary, #334155)";
modeBtn.style.borderColor = "var(--border, #64748b)";
modeBtn.style.color = "var(--muted-foreground, #f8fafc)";
textArea.style.fontFamily = "monospace";
textArea.style.whiteSpace = "pre-wrap";
textArea.textContent = rawText;
+149 -11
View File
@@ -1,4 +1,5 @@
import { app } from "/scripts/app.js";
import { t } from "../core/i18n.js";
const FILE_EXT_REGEX = /\.(safetensors|ckpt|pt|pth|bin|latent|onnx|engine|gguf|lora|zip|tar|gz|7z|json|csv|parquet|txt|yaml|yml|xml|pdf|blend|fbx|obj|glb|gltf|stl|ply|splat|spz|ksplat|png|jpg|jpeg|webp|gif|bmp|tiff|svg|mp4|webm|wav|mp3|ogg|flac|m4a|aac|opus)$/i;
@@ -67,17 +68,59 @@ export function parseWorkflow(workflow) {
return null;
}
export function isNodeIgnored(nodeId, rawWorkflow) {
export function getWorkflowNodeMap(rawWorkflow) {
const workflow = parseWorkflow(rawWorkflow);
if (workflow && Array.isArray(workflow.nodes)) {
const node = workflow.nodes.find(n => String(n.id) === String(nodeId));
if (node?.properties?.ignoreInQueue !== undefined) {
return !!node.properties.ignoreInQueue;
if (!workflow || !Array.isArray(workflow.nodes)) return null;
if (workflow._nodeMap instanceof Map) {
return workflow._nodeMap;
}
const map = new Map();
for (const node of workflow.nodes) {
if (node && node.id !== undefined && node.id !== null) {
map.set(String(node.id), node);
}
}
if (!workflow && app.graph) {
const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(String(nodeId))) : null;
// Attach as non-enumerable so JSON.stringify and Object.keys never see it
if (typeof workflow === "object") {
try {
Object.defineProperty(workflow, "_nodeMap", {
value: map,
writable: true,
configurable: true,
enumerable: false
});
} catch (_) {
workflow._nodeMap = map;
}
}
return map;
}
export function isNodeIgnored(nodeId, rawWorkflow) {
const idStr = String(nodeId);
const nodeMap = getWorkflowNodeMap(rawWorkflow);
if (nodeMap) {
const node = nodeMap.get(idStr);
if (node?.properties?.ignoreInQueue !== undefined) {
return !!node.properties.ignoreInQueue;
}
} else {
const workflow = parseWorkflow(rawWorkflow);
if (workflow && Array.isArray(workflow.nodes)) {
const node = workflow.nodes.find(n => String(n.id) === idStr);
if (node?.properties?.ignoreInQueue !== undefined) {
return !!node.properties.ignoreInQueue;
}
}
}
if (app.graph) {
const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(idStr)) : null;
if (canvasNode?.properties?.ignoreInQueue !== undefined) {
return !!canvasNode.properties.ignoreInQueue;
}
@@ -87,13 +130,21 @@ export function isNodeIgnored(nodeId, rawWorkflow) {
export function getNodeTitle(nodeId, rawWorkflow, promptGraph = null) {
const idStr = String(nodeId);
const nodeMap = getWorkflowNodeMap(rawWorkflow);
const workflow = parseWorkflow(rawWorkflow);
if (workflow && Array.isArray(workflow.nodes)) {
const node = workflow.nodes.find(n => String(n.id) === idStr);
if (nodeMap) {
const node = nodeMap.get(idStr);
if (node) {
return node.title || node.type || null;
}
} else {
const workflow = parseWorkflow(rawWorkflow);
if (workflow && Array.isArray(workflow.nodes)) {
const node = workflow.nodes.find(n => String(n.id) === idStr);
if (node) {
return node.title || node.type || null;
}
}
}
if (promptGraph && promptGraph[idStr]) {
@@ -233,9 +284,10 @@ export function findImagesInOutputs(outputs, rawWorkflow) {
};
if (outputs) {
const nodeMap = getWorkflowNodeMap(workflow);
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
const node = workflow?.nodes?.find(n => String(n.id) === String(nodeId));
const node = nodeMap ? nodeMap.get(String(nodeId)) : workflow?.nodes?.find(n => String(n.id) === String(nodeId));
const nodeType = node ? (node.type || "") : "";
scan(outputs[nodeId], "", "output", nodeType);
}
@@ -374,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;
@@ -444,4 +497,89 @@ export function animateCardRemoval(cardEl, callback, duration = 200) {
setTimeout(() => {
if (callback) callback();
}, duration);
}
export function getScrollContainer(element) {
if (!element) return null;
return element.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|| element.parentElement
|| element;
}
export function calculateGridColumns(width, threshold = 350) {
if (!width || width <= 0) return 1;
return Math.max(1, Math.floor(width / ((threshold || 350) / 2)));
}
export function downloadJson(data, filename) {
if (!data) return;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || "workflow.json";
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
export function setupSpotlightTracker(container, cardSelector = ".comfy-sidebar-card") {
if (!container) return;
container.addEventListener("pointermove", (e) => {
const card = e.target.closest(cardSelector);
if (card) {
const rect = card.getBoundingClientRect();
card.style.setProperty("--mouse-x", `${e.clientX - rect.left}px`);
card.style.setProperty("--mouse-y", `${e.clientY - rect.top}px`);
}
}, { passive: true });
}
export function attachDeleteConfirmation(btn, options = {}) {
if (!btn) return () => {};
const {
canDiskDelete = true,
timeout = 2000,
idleTitle = t("Delete"),
confirmTitle = t("Click again to confirm"),
confirmDiskTitle = t("Ctrl+Click again to delete from disk"),
onConfirm
} = options;
let isPending = false;
let isDisk = false;
let timer = null;
const reset = () => {
isPending = false;
isDisk = false;
btn.classList.remove("confirm-delete", "confirm-delete-disk");
btn.title = typeof idleTitle === "function" ? idleTitle() : idleTitle;
if (timer) {
clearTimeout(timer);
timer = null;
}
};
reset();
btn.onclick = async (ev) => {
ev.stopPropagation();
const wantsDisk = (ev.ctrlKey || ev.metaKey) && (typeof canDiskDelete === "function" ? canDiskDelete() : !!canDiskDelete);
if (!isPending) {
isPending = true;
isDisk = wantsDisk;
btn.classList.add(wantsDisk ? "confirm-delete-disk" : "confirm-delete");
btn.title = wantsDisk
? (typeof confirmDiskTitle === "function" ? confirmDiskTitle() : confirmDiskTitle)
: (typeof confirmTitle === "function" ? confirmTitle() : confirmTitle);
timer = setTimeout(reset, timeout);
} else {
const finalDisk = isDisk || wantsDisk;
reset();
if (onConfirm) await onConfirm(finalDisk);
}
};
return reset;
}
+74 -30
View File
@@ -65,10 +65,14 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
const header = document.createElement("div");
Object.assign(header.style, {
position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "16px", zIndex: "30", color: "#aaa", fontSize: "12px",
fontFamily: "monospace", pointerEvents: "none", background: "rgba(10,10,10,0.75)",
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(4px)",
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center"
display: "flex", gap: "16px", zIndex: "30",
color: "var(--muted-foreground, var(--desc-color, #aaa))",
fontSize: "12px",
fontFamily: "monospace", pointerEvents: "none",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 90%, transparent)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(8px)",
boxShadow: "0 2px 8px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center"
});
const titleText = document.createElement("span");
titleText.textContent = getFilenameFromUrl(baseSrc) || t("3D Model");
@@ -85,9 +89,13 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
const loadingSpinner = document.createElement("div");
Object.assign(loadingSpinner.style, {
position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
color: "#60a5fa", fontSize: "14px", fontFamily: "sans-serif", zIndex: "20",
background: "rgba(0,0,0,0.8)", padding: "10px 18px", borderRadius: "8px",
display: "flex", alignItems: "center", gap: "10px"
color: "var(--primary, var(--p-primary-color, #ea580c))",
fontSize: "14px", fontFamily: "sans-serif", zIndex: "20",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 92%, transparent)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
padding: "10px 18px", borderRadius: "8px",
display: "flex", alignItems: "center", gap: "10px",
boxShadow: "0 8px 24px rgba(0,0,0,0.5)"
});
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> ${t("Loading 3D Asset...")}`;
overlay.container.appendChild(loadingSpinner);
@@ -95,9 +103,13 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
const settingsPanel = document.createElement("div");
Object.assign(settingsPanel.style, {
position: "absolute", top: "60px", right: "20px", width: "230px",
background: "rgba(18, 18, 24, 0.9)", backdropFilter: "blur(8px)",
border: "1px solid rgba(255, 255, 255, 0.12)", borderRadius: "8px",
padding: "14px", zIndex: "35", color: "#e2e8f0", fontSize: "11px",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 94%, transparent)",
backdropFilter: "blur(12px)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
borderRadius: "8px",
padding: "14px", zIndex: "35",
color: "var(--foreground, #e2e8f0)",
fontSize: "11px",
boxShadow: "0 8px 24px rgba(0,0,0,0.6)", display: "none", flexDirection: "column", gap: "12px"
});
@@ -106,7 +118,7 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
Object.assign(row.style, { display: "flex", flexDirection: "column", gap: "4px" });
const lbl = document.createElement("label");
lbl.textContent = label;
Object.assign(lbl.style, { color: "#94a3b8", fontSize: "10px", textTransform: "uppercase", fontWeight: "bold" });
Object.assign(lbl.style, { color: "var(--muted-foreground, #94a3b8)", fontSize: "10px", textTransform: "uppercase", fontWeight: "bold" });
row.append(lbl, inputEl);
return row;
};
@@ -114,7 +126,9 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
const createSelect = (options, value, onChange) => {
const sel = document.createElement("select");
Object.assign(sel.style, {
background: "#252530", color: "#eee", border: "1px solid #444",
background: "var(--secondary, #252530)",
color: "var(--foreground, #eee)",
border: "1px solid var(--border, var(--border-color, #444))",
borderRadius: "4px", padding: "4px 6px", fontSize: "11px", outline: "none"
});
options.forEach(opt => {
@@ -131,9 +145,11 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
const toolbar = document.createElement("div");
Object.assign(toolbar.style, {
position: "absolute", bottom: "20px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "8px", zIndex: "30", background: "rgba(20,20,25,0.85)",
padding: "6px 12px", borderRadius: "8px", backdropFilter: "blur(6px)",
boxShadow: "0 4px 12px rgba(0,0,0,0.6)", border: "1px solid rgba(255,255,255,0.1)",
display: "flex", gap: "8px", zIndex: "30",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 92%, transparent)",
padding: "6px 12px", borderRadius: "8px", backdropFilter: "blur(8px)",
boxShadow: "0 4px 12px rgba(0,0,0,0.6)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
alignItems: "center"
});
@@ -141,19 +157,25 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
const btn = document.createElement("button");
let active = activeDefault;
btn.className = "comfy-sidebar-3d-tool-btn";
const updateBtnTheme = () => {
btn.style.background = active ? "var(--primary, var(--p-primary-color, #ea580c))" : "transparent";
btn.style.color = active ? "#ffffff" : "var(--muted-foreground, #aaa)";
btn.style.borderColor = active ? "var(--primary, var(--p-primary-color, #ea580c))" : "var(--border, var(--border-color, #444))";
};
Object.assign(btn.style, {
background: active ? "#3b82f6" : "transparent", color: active ? "#fff" : "#aaa",
border: "1px solid " + (active ? "#3b82f6" : "#444"), borderRadius: "4px",
padding: "4px 10px", fontSize: "11px", cursor: "pointer", display: "flex",
alignItems: "center", gap: "6px", transition: "all 0.15s ease"
borderRadius: "4px", padding: "4px 10px", fontSize: "11px",
cursor: "pointer", display: "flex", alignItems: "center", gap: "6px",
transition: "all 0.15s ease", border: "1px solid"
});
updateBtnTheme();
btn.innerHTML = `<i class="${iconClass}"></i><span>${label}</span>`;
btn.onclick = (e) => {
e.stopPropagation();
active = !active;
btn.style.background = active ? "#3b82f6" : "transparent";
btn.style.color = active ? "#fff" : "#aaa";
btn.style.borderColor = active ? "#3b82f6" : "#444";
updateBtnTheme();
onClick(active);
};
return btn;
@@ -321,7 +343,19 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
dirLight2.position.set(-5, -5, -5);
scene.add(dirLight2);
gridHelper = new THREE.GridHelper(10, 20, 0x3b82f6, 0x22222a);
let gridPrimaryColor = 0xea580c;
try {
const tempDiv = document.createElement("div");
tempDiv.style.color = "var(--primary, var(--p-primary-color, #ea580c))";
document.body.appendChild(tempDiv);
const rgb = getComputedStyle(tempDiv).color.match(/\d+/g);
tempDiv.remove();
if (rgb && rgb.length >= 3) {
gridPrimaryColor = (Number(rgb[0]) << 16) + (Number(rgb[1]) << 8) + Number(rgb[2]);
}
} catch (e) {}
gridHelper = new THREE.GridHelper(10, 20, gridPrimaryColor, 0x22222a);
gridHelper.position.y = 0;
scene.add(gridHelper);
@@ -415,11 +449,21 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
const btnResetCam = document.createElement("button");
Object.assign(btnResetCam.style, {
background: "transparent", color: "#aaa", border: "1px solid #444",
background: "transparent",
color: "var(--muted-foreground, #aaa)",
border: "1px solid var(--border, var(--border-color, #444))",
borderRadius: "4px", padding: "4px 10px", fontSize: "11px", cursor: "pointer",
display: "flex", alignItems: "center", gap: "6px"
display: "flex", alignItems: "center", gap: "6px", transition: "all 0.15s ease"
});
btnResetCam.innerHTML = `<i class="pi pi-compass"></i><span>${t("Reset View")}</span>`;
btnResetCam.onmouseenter = () => {
btnResetCam.style.borderColor = "var(--primary, var(--border-color, #fff))";
btnResetCam.style.color = "var(--foreground, #fff)";
};
btnResetCam.onmouseleave = () => {
btnResetCam.style.borderColor = "var(--border, var(--border-color, #444))";
btnResetCam.style.color = "var(--muted-foreground, #aaa)";
};
btnResetCam.onclick = (e) => {
e.stopPropagation();
if (currentModel) fitCameraToObject(camera, currentModel, controls, THREE);
@@ -440,9 +484,9 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
exportSelect.value = "";
}
});
exportSelect.style.background = "#1e293b";
exportSelect.style.borderColor = "#3b82f6";
exportSelect.style.color = "#93c5fd";
exportSelect.style.background = "var(--secondary, var(--card, #1e293b))";
exportSelect.style.borderColor = "var(--primary, var(--p-primary-color, #ea580c))";
exportSelect.style.color = "var(--foreground, #fff)";
toolbar.append(btnGrid, btnWire, btnRotate, btnResetCam, btnSettings, exportSelect);
overlay.container.appendChild(toolbar);
@@ -493,12 +537,12 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
} else if (ext === "stl" && THREE.STLLoader) {
const loader = new THREE.STLLoader();
const geometry = await loader.loadAsync(srcUrl);
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4, metalness: 0.2 });
const material = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.4, metalness: 0.2 });
loadedObj = new THREE.Mesh(geometry, material);
} else if (ext === "ply" && THREE.PLYLoader) {
const loader = new THREE.PLYLoader();
const geometry = await loader.loadAsync(srcUrl);
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4 });
const material = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.4 });
loadedObj = new THREE.Mesh(geometry, material);
} else if (THREE.GLTFLoader) {
const loader = new THREE.GLTFLoader();
+199
View File
@@ -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
View File
@@ -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.1"
version = "1.8.3"
license = { file = "LICENSE" }
dependencies = [
"send2trash>=1.8.0",