Compare commits

..
64 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
M0rtus59 cad4098496 UI/UX improvements 2026-11-06 16:35:46 +05:00
m0rtus59 6c3422e5c0 gallery improve nav panel usability 2026-11-05 23:57:51 +05:00
m0rtus59 2554e6dc45 New Gallery sidebar (optional) 2026-11-05 23:20:44 +05:00
m0rtus59 cc8337f6e6 Update compatibility with the latest frontend version 2026-11-04 17:34:28 +05:00
m0rtus59 b4a21ef852 update node renumber script for latest backend 2026-10-25 22:28:51 +05:00
M0rtus59 65e53946d2 fix 'properties' button dupe 2026-10-18 21:47:14 +05:00
m0rtus59 b1d784e9e4 version bump 2026-10-17 11:14:57 +05:00
m0rtus59 2b91e5ef72 update locales 2026-10-17 11:13:53 +05:00
56 changed files with 3780 additions and 792 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
__pycache__/
*.pyc
Thumbs.db
.DS_Store
.DS_Store
desktop.ini
+10
View File
@@ -18,6 +18,10 @@ 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, 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.
<img src="assets/runtime.png" alt="ComfySidebar runtime mode" width="100%">
@@ -58,6 +62,8 @@ Open **Settings → Comfy Sidebar** to configure:
* Graph button visibility
* Unified top bar layout
* Permanent deletion behavior
* Output Gallery sidebar toggle
* Fix non-ASCII keybinds (hotkeys on any keyboard layout)
## ⌨️ Controls
@@ -70,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).
+176 -3
View File
@@ -1,10 +1,14 @@
import asyncio
import os
import platform
import subprocess
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"
@@ -60,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:
@@ -117,6 +146,150 @@ def open_file_in_os(target_path):
raise RuntimeError("Headless Linux server detected (no desktop GUI available).")
_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")
async def gallery_list_handler(request):
try:
subfolder = request.query.get("subfolder", "").strip().strip("/\\")
search = request.query.get("search", "").strip().lower()
# 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:
@@ -153,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)
+131 -25
View File
@@ -1,4 +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"]');
@@ -26,7 +28,7 @@ export function findOriginalPropertiesButton() {
}
export function findTopbarContainer() {
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
const container = document.querySelector('.actionbar-container, [data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
if (container) return container;
const orig = findOriginalPropertiesButton();
@@ -70,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;
@@ -156,6 +163,11 @@ export function findNativeExtensionsPanel() {
}
export function findGraphButton() {
const byTestId = document.querySelector('[data-testid="view-mode-toggle"]');
if (byTestId && !byTestId.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) {
return byTestId;
}
const elements = document.querySelectorAll('.p-1.bg-base-background.rounded-lg, .bg-base-background.rounded-lg, .bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto, [data-testid="graph-view-button"]');
for (const el of elements) {
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
@@ -230,39 +242,133 @@ export function updateSidebarBadge(count) {
}
}
export function applySidebarOverride() {
const overrideStock = app.ui.settings?.getSettingValue?.("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const stdBtn = findStandardQueueButton();
const ourBtn = findOurSidebarButton();
let isApplyingSidebarOverride = false;
if (stdBtn) {
if (!stdBtn._originalDisplay) {
stdBtn._originalDisplay = window.getComputedStyle(stdBtn).display || "block";
export function findSidebarMenuButton(sidebar) {
const root = sidebar || document.querySelector('[data-testid="sidebar-top-group"]') ||
document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar');
if (!root) return null;
// 1. Look for explicit menu or logo test ID
const byTestId = root.querySelector(
'[data-testid*="menu-button"], [data-testid*="app-menu"], [data-testid*="comfy-logo"], [data-testid="sidebar-menu-button"]'
);
if (byTestId) {
return byTestId.closest('button, [role="button"], .comfyui-sidebar-tab, .comfyui-sidebar-item') || byTestId;
}
// 2. Look for title, tooltip, or aria-label containing "menu"
const candidates = Array.from(root.querySelectorAll('button, [role="button"], a, .comfyui-sidebar-tab, .comfyui-sidebar-item'));
const menuBtn = candidates.find(el => {
if (el.classList.contains('comfy-sidebar-tab') || el.id?.includes('classic-comfy-sidebar')) return false;
const title = (el.getAttribute('title') || el.getAttribute('aria-label') || el.textContent || '').trim().toLowerCase();
return title === 'menu' || title.startsWith('menu');
});
if (menuBtn) return menuBtn;
// 3. Fallback: the first child element that is not our button and not the stock queue button
const ourBtn = findOurSidebarButton();
const stdBtn = findStandardQueueButton();
for (const child of root.children) {
if (child !== ourBtn && child !== stdBtn) {
return child;
}
}
return null;
}
export function applySidebarOverride() {
if (isApplyingSidebarOverride) return;
isApplyingSidebarOverride = true;
try {
const overrideStock = (
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();
const topGroup = document.querySelector('[data-testid="sidebar-top-group"]') ||
(ourBtn ? ourBtn.closest('[data-testid="sidebar-top-group"]') : null);
if (ourBtn && !ourBtn._capturedOriginal) {
ourBtn._capturedOriginal = true;
ourBtn._originalParent = ourBtn.parentNode;
ourBtn._originalNextSibling = ourBtn.nextSibling;
}
if (overrideStock) {
stdBtn.style.setProperty("display", "none", "important");
if (!stdBtn._overrideClickListener) {
stdBtn._overrideClickListener = (e) => {
e.preventDefault();
e.stopPropagation();
const b = findOurSidebarButton();
if (b) b.click();
};
stdBtn.addEventListener('click', stdBtn._overrideClickListener, true);
if (stdBtn) {
if (!stdBtn._originalDisplay) {
stdBtn._originalDisplay = window.getComputedStyle(stdBtn).display || "block";
}
if (stdBtn.style.display !== "none") {
stdBtn.style.setProperty("display", "none", "important");
}
if (!stdBtn._overrideClickListener) {
stdBtn._overrideClickListener = (e) => {
e.preventDefault();
e.stopPropagation();
const b = findOurSidebarButton();
if (b) b.click();
};
stdBtn.addEventListener('click', stdBtn._overrideClickListener, true);
}
}
if (ourBtn && stdBtn.parentNode && ourBtn.nextSibling !== stdBtn) {
stdBtn.parentNode.insertBefore(ourBtn, stdBtn);
if (ourBtn) {
const parent = (topGroup && topGroup.contains(ourBtn)) ? topGroup : (stdBtn ? stdBtn.parentNode : ourBtn.parentNode);
if (parent) {
const menuBtn = findSidebarMenuButton(parent);
if (menuBtn && menuBtn.parentNode === parent) {
// Place Queue immediately below the Menu button
if (menuBtn.nextSibling !== ourBtn) {
parent.insertBefore(ourBtn, menuBtn.nextSibling);
}
} else if (stdBtn && stdBtn.parentNode === parent) {
// Place Queue right where stock Job History was
if (stdBtn.previousSibling !== ourBtn && stdBtn !== ourBtn) {
parent.insertBefore(ourBtn, stdBtn);
}
} else {
// Fallback: top of the group
const first = parent.firstElementChild;
if (first && first !== ourBtn) {
parent.insertBefore(ourBtn, first);
}
}
}
}
} else {
stdBtn.style.setProperty("display", stdBtn._originalDisplay === "none" ? "block" : stdBtn._originalDisplay);
if (stdBtn._overrideClickListener) {
stdBtn.removeEventListener('click', stdBtn._overrideClickListener, true);
stdBtn._overrideClickListener = null;
if (stdBtn) {
if (stdBtn.style.display === "none") {
stdBtn.style.removeProperty("display");
if (stdBtn._originalDisplay && stdBtn._originalDisplay !== "none") {
stdBtn.style.display = stdBtn._originalDisplay;
}
}
if (stdBtn._overrideClickListener) {
stdBtn.removeEventListener('click', stdBtn._overrideClickListener, true);
stdBtn._overrideClickListener = null;
}
}
if (ourBtn && stdBtn.parentNode && ourBtn.parentNode === stdBtn.parentNode && ourBtn !== stdBtn.parentNode.lastChild) {
stdBtn.parentNode.appendChild(ourBtn);
if (ourBtn) {
if (ourBtn._originalNextSibling && ourBtn._originalNextSibling.parentNode === ourBtn.parentNode) {
if (ourBtn.nextSibling !== ourBtn._originalNextSibling) {
ourBtn.parentNode.insertBefore(ourBtn, ourBtn._originalNextSibling);
}
} else if (ourBtn._originalParent && ourBtn.parentNode === ourBtn._originalParent) {
if (ourBtn !== ourBtn.parentNode.lastElementChild) {
ourBtn.parentNode.appendChild(ourBtn);
}
}
}
}
} finally {
isApplyingSidebarOverride = false;
}
}
+107 -115
View File
@@ -6,9 +6,12 @@ import {
findActiveQueueIndicator,
findNativeExtensionsPanel,
findGraphButton,
findPropertiesPanel
findPropertiesPanel,
applySidebarOverride
} 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(() => {
@@ -18,20 +21,22 @@ setRuntimePreviewStateListener(() => {
const STYLE_ID = "comfy-sidebar-classic-layout-override";
const CLASSIC_LAYOUT_CSS_MEDIA = `
/* Neutralize the leftover floating shell box to prevent empty dark square artifacts */
.actionbar-container,
.shadow-interface.rounded-lg.bg-comfy-menu-bg:not(.floating-panel):not([role="toolbar"]),
.shadow-interface.rounded-lg.border-interface-stroke:not(.floating-panel):not([role="toolbar"]),
.shadow-interface.border.rounded-lg:has([class*="actionbar-buttons"]),
.shadow-interface.border.rounded-lg:has(.actionbar-buttons),
div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]),
div.shadow-interface.py-1\\.75 {
/* Neutralize the leftover floating shell box to prevent empty dark square/circle artifacts */
[data-testid="action-bar-card"],
.floating-panel:has(.actionbar-container),
.floating-panel:has([class*="actionbar"]),
div.shadow-interface:has(.actionbar-container),
div.shadow-interface:has([class*="actionbar-buttons"]),
div:has(> .actionbar-container):not(header):not(.topbar),
div:has(> [class*="actionbar"]):not(header):not(.topbar):not(.actionbar-container) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
outline: none !important;
}
/* Dock actionbar buttons in the topbar row with transparent background */
.actionbar-container,
[class*="actionbar"]:not(.actionbar),
[class*="actionbar-buttons"],
.actionbar-buttons {
@@ -84,28 +89,42 @@ div.shadow-interface.py-1\\.75 {
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;
let domObserver = null;
let sidebarTopObserver = null;
let syncScheduled = false;
let actionbarResizeObserver = null;
function setupSidebarTopObserver() {
if (sidebarTopObserver) return;
const target = document.querySelector('[data-testid="sidebar-top-group"]') ||
document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar');
if (!target) return;
sidebarTopObserver = new MutationObserver(() => {
applySidebarOverride();
updateSidebarTabsVisibility();
});
sidebarTopObserver.observe(target, {
childList: true,
subtree: true
});
}
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;
@@ -344,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")) {
@@ -371,7 +389,7 @@ function setupCanvasMenuPopoverRelocator() {
}
function updateTopMetrics() {
const bar = document.querySelector('.actionbar-buttons, [class*="actionbar-buttons"]') || findTopbarContainer();
const bar = document.querySelector('.actionbar-container, .actionbar-buttons, [class*="actionbar-buttons"]') || findTopbarContainer();
const tabsEl = document.querySelector('.workflow-tabs-container');
const topbar = tabsEl?.closest('header, .topbar, [class*="topbar"]') || tabsEl;
@@ -403,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");
@@ -431,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);
@@ -479,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;
@@ -491,7 +505,11 @@ function updateSidebarTabsVisibility() {
}
});
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const hideStockHistory = (
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) {
const historyBtn = historyTarget.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyTarget;
@@ -530,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) {
@@ -578,10 +596,6 @@ function syncPropertiesButton(isClassicLayoutEnabled) {
const openState = isPropertiesPanelOpen();
if (originalBtn) {
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
}
savedButtonData = {
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
innerHTML: originalBtn.innerHTML,
@@ -591,6 +605,26 @@ function syncPropertiesButton(isClassicLayoutEnabled) {
value: attr.value
}))
};
// When closed: keep originalBtn visible (ComfyUI renders error outlines & dot natively).
// When open: hide originalBtn so our customBtn takes its pinned place.
if (openState) {
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
}
} else {
if (originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
}
}
// Clicking the native button to open the panel triggers an instant layout sync
if (!originalBtn._syncAttached) {
originalBtn._syncAttached = true;
originalBtn.addEventListener("click", () => {
requestAnimationFrame(() => syncClassicLayout());
});
}
}
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
@@ -609,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());
}
}
});
@@ -621,36 +661,16 @@ function syncPropertiesButton(isClassicLayoutEnabled) {
}
if (customBtn) {
if (originalBtn) {
const cleanedHTML = cleanHTML(originalBtn.innerHTML);
if (cleanedHTML && customBtn.innerHTML !== cleanedHTML) {
const existingDot = customBtn.querySelector('.comfy-sidebar-error-dot');
customBtn.innerHTML = cleanedHTML;
if (existingDot) customBtn.appendChild(existingDot);
}
const titleVal = originalBtn.getAttribute("title") || originalBtn.getAttribute("aria-label") || "Toggle properties panel";
customBtn.setAttribute("title", titleVal);
customBtn.setAttribute("aria-label", titleVal);
for (const cls of originalBtn.classList) {
if (cls !== "comfy-sidebar-hide-original-properties-btn" && !customBtn.classList.contains(cls)) {
customBtn.classList.add(cls);
}
}
syncErrorBadge(originalBtn, customBtn);
} else if (!customBtn.getAttribute("title")) {
if (!customBtn.getAttribute("title")) {
customBtn.setAttribute("title", "Toggle properties panel");
customBtn.setAttribute("aria-label", "Toggle properties panel");
}
customBtn.classList.toggle("comfy-panel-open", openState);
customBtn.style.display = "inline-flex";
}
}
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) {
@@ -669,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();
@@ -717,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>`;
@@ -724,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).";
}
@@ -745,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";
@@ -779,6 +757,10 @@ export function syncClassicLayout() {
syncPropertiesButton(isClassicLayoutEnabled);
syncActiveQueueIndicator(isClassicLayoutEnabled);
updateSidebarTabsVisibility();
applySidebarOverride();
if (!sidebarTopObserver) {
setupSidebarTopObserver();
}
syncRuntimePreviewButton();
} catch (err) {
@@ -796,6 +778,7 @@ export function syncClassicLayout() {
export function setupPropertiesPanelToggleFix() {
setupCanvasMenuTooltipRelocator();
setupCanvasMenuPopoverRelocator();
setupSidebarTopObserver();
if (!document.getElementById("comfy-sidebar-layout-fix-styles")) {
const style = document.createElement("style");
@@ -812,15 +795,15 @@ export function setupPropertiesPanelToggleFix() {
order: 99999 !important;
position: relative !important;
overflow: visible !important;
margin-left: -8px !important;
margin-left: 0px !important;
margin-right: 0px !important;
display: inline-flex !important;
display: none !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
}
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
margin-left: 0px !important;
display: inline-flex !important;
}
.comfy-sidebar-custom-properties-toggle > span.comfy-sidebar-error-dot {
display: inline-flex !important;
@@ -932,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 */
@@ -1087,6 +1075,10 @@ export function destroyLayoutFix() {
domObserver.disconnect();
domObserver = null;
}
if (sidebarTopObserver) {
sidebarTopObserver.disconnect();
sidebarTopObserver = null;
}
if (actionbarResizeObserver) {
actionbarResizeObserver.disconnect();
actionbarResizeObserver = null;
+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",
+24 -37
View File
@@ -4,19 +4,28 @@ let currentLocale = "en";
let translations = {};
let fallbackTranslations = {};
/**
* Initializes the localization engine by reading ComfyUI's language setting
* and dynamically loading the corresponding JSON dictionary.
*/
async function fetchLocaleFiles(lang) {
const data = {};
for (const file of ["main.json", "settings.json"]) {
try {
const url = new URL(`../locales/${lang}/${file}`, import.meta.url).href;
const res = await fetch(url);
if (res.ok) {
const json = await res.json();
Object.assign(data, json);
}
} catch (e) {}
}
return data;
}
export async function initI18n() {
try {
// ComfyUI's internal setting ID is "Comfy.Locale"
let settingVal = app?.extensionManager?.setting?.get?.("Comfy.Locale")
|| app?.ui?.settings?.getSettingValue?.("Comfy.Locale")
|| app?.extensionManager?.setting?.get?.("Comfy.Locale.Language")
|| app?.ui?.settings?.getSettingValue?.("Comfy.Locale.Language");
// Fallback check in localStorage in case settings API hasn't populated yet
if (!settingVal) {
try {
settingVal = localStorage.getItem("Comfy.Settings.Comfy.Locale")
@@ -25,7 +34,6 @@ export async function initI18n() {
}
const raw = (settingVal || "en").toLowerCase().replace("_", "-");
// Check regional locale first (e.g. "zh-tw", "pt-br"), then base ("zh", "pt")
const candidates = [raw];
const base = raw.split("-")[0];
if (base !== raw) candidates.push(base);
@@ -33,34 +41,18 @@ export async function initI18n() {
currentLocale = candidates[0];
console.log("[ComfySidebar] Detected locale:", currentLocale);
// 1. Load English fallback dictionary
try {
const enUrl = new URL("../locales/en.json", import.meta.url).href;
const enRes = await fetch(enUrl);
if (enRes.ok) {
fallbackTranslations = await enRes.json();
}
} catch (e) {
console.warn("[ComfySidebar] Failed to load en.json fallback", e);
}
// 1. Load English fallback
fallbackTranslations = await fetchLocaleFiles("en");
// 2. If language is not English, load target locale
// 2. Load active locale if not English
if (base !== "en") {
let loaded = false;
for (const cand of candidates) {
try {
const locUrl = new URL(`../locales/${cand}.json`, import.meta.url).href;
const locRes = await fetch(locUrl);
if (locRes.ok) {
translations = await locRes.json();
console.log(`[ComfySidebar] Loaded ${cand}.json with`, Object.keys(translations).length, "translations");
loaded = true;
break;
}
} catch (e) {}
}
if (!loaded) {
console.warn(`[ComfySidebar] No translation file found for: ${candidates.join(" / ")}`);
const locData = await fetchLocaleFiles(cand);
if (Object.keys(locData).length > 0) {
translations = locData;
console.log(`[ComfySidebar] Loaded ${cand} with ${Object.keys(translations).length} translations`);
break;
}
}
}
} catch (e) {
@@ -68,11 +60,6 @@ export async function initI18n() {
}
}
/**
* Translate a phrase.
* If translation is missing in current language, falls back to English.
* If missing in English too, returns the original text.
*/
export function t(key, vars = {}) {
if (!key) return "";
let str = translations[key] || fallbackTranslations[key] || key;
+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);
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "إعادة ضبط المنظور",
"Settings": "الإعدادات",
"Export As...": "تصدير كـ...",
"Failed to load 3D format": "فشل تحميل تنسيق 3D"
"Failed to load 3D format": "فشل تحميل تنسيق 3D",
"Toggle Comfy Queue Sidebar": "تبديل الشريط الجانبي لـ Comfy Queue",
"Toggle Ignore Selected Node(s) in Queue": "تجاهل/تضمين العقد المحددة في قائمة الانتظار",
"Renumber All Nodes (Execution Order)": "إعادة ترقيم جميع العقد (ترتيب التنفيذ)",
"Set Selected Node ID...": "تعيين معرف العقدة المحددة...",
"Set Node ID...": "تعيين معرف العقدة...",
"Gallery": "المعرض",
"Gallery (Outputs)": "المعرض (المخرجات)",
"Go Up One Level": "الانتقال لمستوى أعلى",
"Open Current Folder in File Manager": "فتح المجلد الحالي في مدير الملفات",
"Refresh Gallery": "تحديث المعرض",
"Filter gallery files...": "تصفية ملفات المعرض...",
"Loading files...": "جاري تحميل الملفات...",
"No files found in this folder.": "لم يتم العثور على ملفات في هذا المجلد.",
"Failed to load gallery from disk.": "فشل تحميل المعرض من القرص.",
"Sort: Newest First": "الترتيب: الأحدث أولاً",
"Sort: Oldest First": "الترتيب: الأقدم أولاً",
"Search Gallery (Filename or Prompt)": "البحث في المعرض (اسم الملف أو الموجه)",
"Search filenames or prompt metadata...": "البحث في أسماء الملفات أو بيانات الموجه..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "حد أعمدة قائمة الانتظار",
"Width Threshold for Queue Columns (px)": "العرض بالبكسل لتقسيم القائمة إلى عدة أعمدة",
"Show Pending Count Only": "إظهار عدد المهام المعلقة فقط",
"If disabled, each queued job will have a separate individual card": "في حال التعطيل، ستظهر كل مهمة في القائمة ببطاقة مستقلة",
"Show Working Node Name": "إظهار اسم العقدة النشطة",
"Shows the name of the node which is currently in the process": "يعرض اسم العقدة التي تتم معالجتها حالياً",
"Auto Clear Interrupted": "مسح تلقائي للمهام المقاطعة",
"Auto-clear cancelled & failed jobs on new generation": "مسح المهام الملغاة والفاشلة تلقائياً عند بدء توليد جديد",
"Permanent Delete on Disk Deletion": "حذف دائم من القرص",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "حذف الملفات نهائياً عند المسح من القرص (دون نقلها لسلة المحذوفات)",
"Hide Tab: {tab}": "إخفاء التبويب: {tab}",
"Assets": "الأصول",
"Nodes": "العقد",
"Models": "النماذج",
"Workflows": "مخططات العمل",
"Apps": "التطبيقات",
"Templates": "القوالب",
"Override Stock History": "استبدال السجل الافتراضي",
"Replace the stock Job History sidebar with Comfy Queue": "استبدال الشريط الجانبي لسجل المهام الافتراضي بـ Comfy Queue",
"Hide Graph Button": "إخفاء زر 'المخطط'",
"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": "مظهر مدمج",
"Hide Junk": "إخفاء العناصر غير المرغوبة",
"Default": "افتراضي",
"Dock": "إرساء",
"Hide": "إخفاء",
"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 عند تفعيل تخطيط آخر. ولا تتأثر الكتابة في حقول النص."
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "Reset View",
"Settings": "Settings",
"Export As...": "Export As...",
"Failed to load 3D format": "Failed to load 3D format"
"Failed to load 3D format": "Failed to load 3D format",
"Toggle Comfy Queue Sidebar": "Toggle Comfy Queue Sidebar",
"Toggle Ignore Selected Node(s) in Queue": "Toggle Ignore Selected Node(s) in Queue",
"Renumber All Nodes (Execution Order)": "Renumber All Nodes (Execution Order)",
"Set Selected Node ID...": "Set Selected Node ID...",
"Set Node ID...": "Set Node ID...",
"Gallery": "Gallery",
"Gallery (Outputs)": "Gallery (Outputs)",
"Go Up One Level": "Go Up One Level",
"Open Current Folder in File Manager": "Open Current Folder in File Manager",
"Refresh Gallery": "Refresh Gallery",
"Filter gallery files...": "Filter gallery files...",
"Loading files...": "Loading files...",
"No files found in this folder.": "No files found in this folder.",
"Failed to load gallery from disk.": "Failed to load gallery from disk.",
"Sort: Newest First": "Sort: Newest First",
"Sort: Oldest First": "Sort: Oldest First",
"Search Gallery (Filename or Prompt)": "Search Gallery (Filename or Prompt)",
"Search filenames or prompt metadata...": "Search filenames or prompt metadata..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "Grid Columns Threshold",
"Width Threshold for Queue Columns (px)": "Width Threshold for Queue Columns (px)",
"Show Pending Count Only": "Show Pending Count Only",
"If disabled, each queued job will have a separate individual card": "If disabled, each queued job will have a separate individual card",
"Show Working Node Name": "Show Working Node Name",
"Shows the name of the node which is currently in the process": "Shows the name of the node which is currently in the process",
"Auto Clear Interrupted": "Auto Clear Interrupted",
"Auto-clear cancelled & failed jobs on new generation": "Auto-clear cancelled & failed jobs on new generation",
"Permanent Delete on Disk Deletion": "Permanent Delete on Disk Deletion",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)",
"Hide Tab: {tab}": "Hide Tab: {tab}",
"Assets": "Assets",
"Nodes": "Nodes",
"Models": "Models",
"Workflows": "Workflows",
"Apps": "Apps",
"Templates": "Templates",
"Override Stock History": "Override Stock History",
"Replace the stock Job History sidebar with Comfy Queue": "Replace the stock Job History sidebar with Comfy Queue",
"Hide Graph Button": "Hide Graph Button",
"Hide floating 'Graph' (Workflow/Node Map) button": "Hide floating 'Graph' (Workflow/Node Map) button",
"Hide Avatar": "Hide Avatar",
"Floating Canvas Controls": "Floating Canvas Controls",
"Classic Layout": "Classic Layout",
"Places the controls and the open workflow tabs on a single unified top bar": "Places the controls and the open workflow tabs on a single unified top bar",
"Comfy Layout": "Comfy Layout",
"Hide Junk": "Hide Junk",
"Default": "Default",
"Dock": "Dock",
"Hide": "Hide",
"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."
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "Restablecer vista",
"Settings": "Ajustes",
"Export As...": "Exportar como...",
"Failed to load 3D format": "Error al cargar el formato 3D"
"Failed to load 3D format": "Error al cargar el formato 3D",
"Toggle Comfy Queue Sidebar": "Alternar barra lateral Comfy Queue",
"Toggle Ignore Selected Node(s) in Queue": "Ignorar/Incluir nodo(s) seleccionado(s) en la cola",
"Renumber All Nodes (Execution Order)": "Renumerar todos los nodos (orden de ejecución)",
"Set Selected Node ID...": "Establecer ID del nodo seleccionado...",
"Set Node ID...": "Establecer ID de nodo...",
"Gallery": "Galería",
"Gallery (Outputs)": "Galería (Salidas)",
"Go Up One Level": "Subir un nivel",
"Open Current Folder in File Manager": "Abrir carpeta actual en el explorador de archivos",
"Refresh Gallery": "Actualizar galería",
"Filter gallery files...": "Filtrar archivos de la galería...",
"Loading files...": "Cargando archivos...",
"No files found in this folder.": "No se encontraron archivos en esta carpeta.",
"Failed to load gallery from disk.": "Error al cargar la galería desde el disco.",
"Sort: Newest First": "Ordenar: más recientes primero",
"Sort: Oldest First": "Ordenar: más antiguos primero",
"Search Gallery (Filename or Prompt)": "Buscar en galería (nombre de archivo o prompt)",
"Search filenames or prompt metadata...": "Buscar nombres de archivo o metadatos de prompt..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "Umbral de columnas de la cola",
"Width Threshold for Queue Columns (px)": "Ancho en píxeles para dividir la cola en varias columnas",
"Show Pending Count Only": "Mostrar solo total en espera",
"If disabled, each queued job will have a separate individual card": "Si se desactiva, cada tarea en cola tendrá una tarjeta individual",
"Show Working Node Name": "Mostrar nombre del nodo en proceso",
"Shows the name of the node which is currently in the process": "Muestra el nombre del nodo que se está procesando actualmente",
"Auto Clear Interrupted": "Limpieza auto de interrumpidos",
"Auto-clear cancelled & failed jobs on new generation": "Borrar automáticamente tareas canceladas y fallidas al iniciar una nueva generación",
"Permanent Delete on Disk Deletion": "Eliminación permanente del disco",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "Eliminar permanentemente archivos al borrarlos (sin pasar por la papelera)",
"Hide Tab: {tab}": "Ocultar pestaña: {tab}",
"Assets": "Recursos",
"Nodes": "Nodos",
"Models": "Modelos",
"Workflows": "Flujos",
"Apps": "Apps",
"Templates": "Plantillas",
"Override Stock History": "Reemplazar historial estándar",
"Replace the stock Job History sidebar with Comfy Queue": "Reemplaza la barra de historial estándar con Comfy Queue",
"Hide Graph Button": "Ocultar botón 'Grafo'",
"Hide floating 'Graph' (Workflow/Node Map) button": "Oculta el botón flotante 'Grafo' (mapa de nodos)",
"Hide Avatar": "Ocultar avatar",
"Floating Canvas Controls": "Controles flotantes del lienzo",
"Classic Layout": "Diseño clásico",
"Places the controls and the open workflow tabs on a single unified top bar": "Coloca los controles y las pestañas abiertas en una barra superior unificada",
"Comfy Layout": "Diseño compacto",
"Hide Junk": "Ocultar elementos innecesarios",
"Default": "Por defecto",
"Dock": "Acoplar",
"Hide": "Ocultar",
"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."
}
+118
View File
@@ -0,0 +1,118 @@
{
"Queue": "صف",
"Search History": "جستجو در تاریخچه",
"Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)": "نادیده گرفتن گره‌های انتخابی در صف (Ctrl+Q)",
"No nodes selected. Click a node on the canvas first to toggle ignore.": "هیچ گرهی انتخاب نشده است. ابتدا روی یک گره در بوم کلیک کنید.",
"Renumber All Nodes by Execution Order (Alt+R)": "شماره‌گذاری مجدد گره‌ها بر اساس ترتیب اجرا (Alt+R)",
"Clear Cancelled & Failed": "پاک کردن موارد لغو شده و ناموفق",
"Clear All History": "پاک کردن تمام تاریخچه",
"Filter by text, images, nodes...": "فیلتر بر اساس متن، تصویر، گره...",
"Clear & Close Search": "پاک کردن و بستن جستجو",
"Scroll to Top": "رفتن به بالا",
"Batch of #": "دسته #",
"Go Back": "بازگشت",
"Go Back to Queue": "بازگشت به صف",
"Outputs of #": "خروجی‌های #",
"No separate intermediate outputs found.": "هیچ خروجی میانی مجزایی پیدا نشد.",
"Cancel All Pending": "لغو تمام کارهای در انتظار",
"Pending Queue:": "صف در انتظار:",
"tasks": "کار",
"Download Object": "دانلود فایل",
"Copy to Clipboard": "کپی در کلیپ‌بورد",
"Download JSON": "دانلود JSON",
"Delete Card (Hold Ctrl to delete file from disk)": "حذف کارت (Ctrl برای حذف از دیسک)",
"Delete Card": "حذف کارت",
"Delete File": "حذف فایل",
"Delete Files": "حذف فایل‌ها",
"Show Node": "نمایش گره",
"Show Stopped/Failed Node": "نمایش گره متوقف یا ناموفق",
"View all intermediate outputs": "مشاهده تمام خروجی‌های میانی",
"Delete Card (Ctrl+Click to delete from disk)": "حذف کارت (Ctrl+کلیک برای حذف از دیسک)",
"Ctrl+Click again to delete file from DISK / TRASH": "برای حذف نهایی از دیسک مجدداً Ctrl+کلیک کنید",
"Click again to confirm removing card": "برای تأیید حذف مجدداً کلیک کنید",
"Remove from batch (Hold Ctrl to delete file from disk)": "حذف از دسته (Ctrl برای حذف از دیسک)",
"Click again to confirm removing from batch": "برای تأیید حذف از دسته مجدداً کلیک کنید",
"Delete node output (Hold Ctrl to delete file from disk)": "حذف خروجی گره (Ctrl برای حذف از دیسک)",
"Ctrl+Click again to delete this node's files from DISK / TRASH": "برای حذف فایل‌های این گره از دیسک مجدداً Ctrl+کلیک کنید",
"Click again to confirm removing output": "برای تأیید حذف خروجی مجدداً کلیک کنید",
"Moved file to Recycle Bin.": "فایل به سطل بازیافت منتقل شد.",
"Permanently deleted file.": "فایل به طور دائمی حذف شد.",
"Cancelled": "لغو شد",
"Error": "خطا",
"Warning": "هشدار",
"Cancel Queued Task": "لغو کار در صف",
"Interrupt Execution": "توقف اجرا",
"Click to read full text": "برای خواندن متن کامل کلیک کنید",
"No Outputs": "بدون خروجی",
"Processing...": "در حال پردازش...",
"Finishing...": "در حال اتمام...",
"No workflow metadata found in this file.": "متاداده جریان کاری در این فایل یافت نشد.",
"Open Folder": "باز کردن پوشه",
"Open Location": "باز کردن مسیر",
"View all images in this batch": "مشاهده تمام تصاویر این دسته",
"No nodes found on canvas to renumber.": "گرهی برای شماره‌گذاری مجدد روی بوم یافت نشد.",
"Renumbering will rewrite all node IDs on the canvas in topological execution order.\n\nThis will fix intermediate node outputs showing as the final result, but may break external scripts that rely on specific node IDs\n\nDo you want to proceed?": "شماره‌گذاری مجدد، شناسه تمام گره‌ها را طبق ترتیب اجرا بازنویسی می‌کند.\n\nاین کار خروجی‌های میانی را اصلاح می‌کند اما ممکن است اسکریپت‌های وابسته به شناسه‌ها را مختل کند.\n\nآیا ادامه می‌دهید؟",
"Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).": "{count} گره طبق ترتیب اجرا شماره‌گذاری شدند. آخرین گره: #{id} ({name}).",
"Failed to renumber nodes: ": "شماره‌گذاری مجدد گره‌ها ناموفق بود: ",
"Please select a node first to change its ID.": "لطفاً ابتدا یک گره را برای تغییر شناسه انتخاب کنید.",
"Enter new ID for node #{id} ({name}):": "شناسه جدید برای گره #{id} ({name}) را وارد کنید:",
"Please enter a valid positive number.": "لطفاً یک عدد صحیح مثبت معتبر وارد کنید.",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "شناسه #{id} قبلاً توسط \"{name}\" استفاده شده است. آیا می‌خواهید شناسه‌ها جابجا شوند؟",
"Assigned node ID #{id} to \"{name}\".": "شناسه #{id} به گره \"{name}\" اختصاص یافت.",
"Failed to set node ID: ": "تنظیم شناسه گره ناموفق بود: ",
"Video playback. Press Esc to close.": "پخش ویدیو. برای بستن Esc را بزنید.",
"Click image to zoom (100%/Fit) | Shift+Click another card to compare.": "کلیک برای بزرگنمایی (100%/تناسب) | Shift+کلیک روی کارت دیگر برای مقایسه.",
"Toggle Loop": "تکرار پخش",
"Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.": "عدم تطابق نسبت ابعاد ({wA}x{hA} در برابر {wB}x{hB}). تصاویر برای مقایسه باید نسبت برابری داشته باشند.",
"Click image to zoom | Drag image to compare slider | Esc to close.": "کلیک برای بزرگنمایی | کشیدن اسلایدر برای مقایسه | Esc برای بستن.",
"Copy": "کپی",
"Copied!": "کپی شد!",
"Text Output": "خروجی متنی",
"Text Output #": "خروجی متنی #",
"Raw Text": "متن خام",
"{wc} words • {chars} chars": "{wc} کلمه • {chars} نویسه",
"3D Model": "مدل 3D",
"Loading 3D Asset...": "در حال بارگذاری مدل سه‌بعدی...",
"Exporting {format}...": "در حال صدور به فرمت {format}...",
"Failed to load 3D engine.": "موتور سه‌بعدی بارگذاری نشد.",
"Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.": "امکان دانلود کتابخانه‌های 3D وجود ندارد. اتصال اینترنت خود را بررسی کنید.",
"Background Color": "رنگ پس‌زمینه",
"Up Direction": "جهت بالا",
"Material Mode": "حالت متریال",
"Camera Type": "نوع دوربین",
"FOV": "میدان دید (FOV)",
"Light Intensity": "شدت نور",
"Original (Y-Up)": "اصلی (Y رو به بالا)",
"Z-Up (+90°)": "Z رو به بالا (+90°)",
"Inverted (-90°)": "معکوس (-90°)",
"Original / Textured": "اصلی / بافت‌دار",
"Clay / Studio Matte": "گلی / مات استودیویی",
"Normals": "نرمال‌ها",
"Perspective": "پرسپکتیو",
"Orthographic": "ارتوگرافیک",
"Grid": "شبکه",
"Auto-Rotate": "چرخش خودکار",
"Wireframe": "قاب سیمی (Wireframe)",
"Reset View": "بازنشانی دید",
"Settings": "تنظیمات",
"Export As...": "صادرات به عنوان...",
"Failed to load 3D format": "بارگذاری فرمت 3D ناموفق بود",
"Toggle Comfy Queue Sidebar": "تغییر وضعیت نوار کناری Comfy Queue",
"Toggle Ignore Selected Node(s) in Queue": "نادیده گرفتن/فعال‌سازی گره‌های انتخاب‌شده در صف",
"Renumber All Nodes (Execution Order)": "شماره‌گذاری مجدد همه گره‌ها (ترتیب اجرا)",
"Set Selected Node ID...": "تنظیم شناسه گره انتخاب‌شده...",
"Set Node ID...": "تنظیم شناسه گره...",
"Gallery": "گالری",
"Gallery (Outputs)": "گالری (خروجی‌ها)",
"Go Up One Level": "یک سطح به بالا",
"Open Current Folder in File Manager": "باز کردن پوشه فعلی در مدیریت فایل",
"Refresh Gallery": "به‌روزرسانی گالری",
"Filter gallery files...": "فیلتر کردن فایل‌های گالری...",
"Loading files...": "در حال بارگذاری فایل‌ها...",
"No files found in this folder.": "هیچ فایلی در این پوشه یافت نشد.",
"Failed to load gallery from disk.": "بارگذاری گالری از دیسک ناموفق بود.",
"Sort: Newest First": "مرتب‌سازی: جدیدترین‌ها در ابتدا",
"Sort: Oldest First": "مرتب‌سازی: قدیمی‌ترین‌ها در ابتدا",
"Search Gallery (Filename or Prompt)": "جستجو در گالری (نام فایل یا پرامپت)",
"Search filenames or prompt metadata...": "جستجوی نام فایل‌ها یا متادیتای پرامپت..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "آستانه ستون‌های صف",
"Width Threshold for Queue Columns (px)": "عرض بر حسب پیکسل برای تقسیم صف به چند ستون",
"Show Pending Count Only": "فقط نمایش تعداد در انتظار",
"If disabled, each queued job will have a separate individual card": "در صورت غیرفعال بودن، هر کار در صف یک کارت جداگانه خواهد داشت",
"Show Working Node Name": "نمایش نام گره در حال پردازش",
"Shows the name of the node which is currently in the process": "نام گرهی را که در حال حاضر پردازش می‌شود نشان می‌دهد",
"Auto Clear Interrupted": "پاکسازی خودکار متوقف‌شده‌ها",
"Auto-clear cancelled & failed jobs on new generation": "پاک کردن خودکار کارهای لغو شده و ناموفق در تولید جدید",
"Permanent Delete on Disk Deletion": "حذف دائمی از دیسک",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "حذف دائمی فایل‌ها هنگام پاک کردن از دیسک (بدون انتقال به سطل بازیافت)",
"Hide Tab: {tab}": "پنهان کردن برگه: {tab}",
"Assets": "دارایی‌ها",
"Nodes": "گره‌ها",
"Models": "مدل‌ها",
"Workflows": "جریان‌های کاری",
"Apps": "برنامه‌ها",
"Templates": "الگوها",
"Override Stock History": "جایگزینی تاریخچه پیش‌فرض",
"Replace the stock Job History sidebar with Comfy Queue": "نوار کناری تاریخچه کارهای پیش‌فرض را با Comfy Queue جایگزین می‌کند",
"Hide Graph Button": "پنهان کردن دکمه 'نمودار'",
"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": "طرح‌بندی فشرده",
"Hide Junk": "پنهان کردن موارد غیرضروری",
"Default": "پیش‌فرض",
"Dock": "اتصال",
"Hide": "پنهان",
"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 هنگام فعال بودن چیدمان دیگر نیز کار کنند. تایپ در فیلدهای متنی تحت تأثیر قرار نمی‌گیرد."
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "Réinitialiser la vue",
"Settings": "Paramètres",
"Export As...": "Exporter sous...",
"Failed to load 3D format": "Échec du chargement du format 3D"
"Failed to load 3D format": "Échec du chargement du format 3D",
"Toggle Comfy Queue Sidebar": "Basculer la barre latérale Comfy Queue",
"Toggle Ignore Selected Node(s) in Queue": "Ignorer/Activer les nœuds sélectionnés dans la file",
"Renumber All Nodes (Execution Order)": "Renuméroter tous les nœuds (ordre d'exécution)",
"Set Selected Node ID...": "Définir l'ID du nœud sélectionné...",
"Set Node ID...": "Définir l'ID du nœud...",
"Gallery": "Galerie",
"Gallery (Outputs)": "Galerie (Sorties)",
"Go Up One Level": "Dossier parent",
"Open Current Folder in File Manager": "Ouvrir le dossier actuel dans le gestionnaire de fichiers",
"Refresh Gallery": "Actualiser la galerie",
"Filter gallery files...": "Filtrer les fichiers de la galerie...",
"Loading files...": "Chargement des fichiers...",
"No files found in this folder.": "Aucun fichier trouvé dans ce dossier.",
"Failed to load gallery from disk.": "Échec du chargement de la galerie depuis le disque.",
"Sort: Newest First": "Trier : Plus récents d'abord",
"Sort: Oldest First": "Trier : Plus anciens d'abord",
"Search Gallery (Filename or Prompt)": "Rechercher dans la galerie (nom de fichier ou prompt)",
"Search filenames or prompt metadata...": "Rechercher des noms de fichiers ou métadonnées de prompt..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "Seuil de colonnes",
"Width Threshold for Queue Columns (px)": "Largeur en pixels pour diviser la file en plusieurs colonnes",
"Show Pending Count Only": "Afficher uniquement le total en attente",
"If disabled, each queued job will have a separate individual card": "Si désactivé, chaque tâche en file aura une carte individuelle",
"Show Working Node Name": "Afficher le nom du nœud actif",
"Shows the name of the node which is currently in the process": "Affiche le nom du nœud en cours de traitement",
"Auto Clear Interrupted": "Nettoyage auto des interruptions",
"Auto-clear cancelled & failed jobs on new generation": "Effacer automatiquement les tâches annulées et échouées lors d'une nouvelle génération",
"Permanent Delete on Disk Deletion": "Suppression définitive du disque",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "Supprimer définitivement les fichiers lors de la suppression (sans passer par la corbeille)",
"Hide Tab: {tab}": "Masquer l'onglet : {tab}",
"Assets": "Ressources",
"Nodes": "Nœuds",
"Models": "Modèles",
"Workflows": "Workflows",
"Apps": "Applications",
"Templates": "Modèles",
"Override Stock History": "Remplacer l'historique par défaut",
"Replace the stock Job History sidebar with Comfy Queue": "Remplace la barre latérale d'historique par défaut par Comfy Queue",
"Hide Graph Button": "Masquer le bouton 'Graphe'",
"Hide floating 'Graph' (Workflow/Node Map) button": "Masque le bouton flottant 'Graphe' (carte des nœuds)",
"Hide Avatar": "Masquer l'avatar",
"Floating Canvas Controls": "Contrôles flottants du canevas",
"Classic Layout": "Disposition classique",
"Places the controls and the open workflow tabs on a single unified top bar": "Regroupe les contrôles et les onglets ouverts dans une barre supérieure unique",
"Comfy Layout": "Disposition compacte",
"Hide Junk": "Masquer les éléments inutiles",
"Default": "Par défaut",
"Dock": "Ancrer",
"Hide": "Masquer",
"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."
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "אפס תצוגה",
"Settings": "הגדרות",
"Export As...": "ייצא כ...",
"Failed to load 3D format": "טעינת פורמט 3D נכשלה"
"Failed to load 3D format": "טעינת פורמט 3D נכשלה",
"Toggle Comfy Queue Sidebar": "הפעל/השבת סרגל צד Comfy Queue",
"Toggle Ignore Selected Node(s) in Queue": "התעלם/כלול צמתים נבחרים בתור",
"Renumber All Nodes (Execution Order)": "מספר מחדש את כל הצמתים (סדר ביצוע)",
"Set Selected Node ID...": "הגדר מזהה צומת נבחר...",
"Set Node ID...": "הגדר מזהה צומת...",
"Gallery": "גלריה",
"Gallery (Outputs)": "גלריה (תוצרים)",
"Go Up One Level": "עלה שלב אחד למעלה",
"Open Current Folder in File Manager": "פתח תיקייה נוכחית בסייר הקבצים",
"Refresh Gallery": "רענן גלריה",
"Filter gallery files...": "סנן קבצי גלריה...",
"Loading files...": "טוען קבצים...",
"No files found in this folder.": "לא נמצאו קבצים בתיקייה זו.",
"Failed to load gallery from disk.": "טעינת הגלריה מהכונן נכשלה.",
"Sort: Newest First": "מיון: החדשים ביותר קודם",
"Sort: Oldest First": "מיון: הישנים ביותר קודם",
"Search Gallery (Filename or Prompt)": "חיפוש בגלריה (שם קובץ או פרומפט)",
"Search filenames or prompt metadata...": "חיפוש שמות קבצים או מטא-דאטה של פרומפט..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "סף עמודות תור",
"Width Threshold for Queue Columns (px)": "רוחב בפיקסלים לחלוקת התור לעמודות מרובות",
"Show Pending Count Only": "הצג רק ספירת ממתינים",
"If disabled, each queued job will have a separate individual card": "אם מושבת, לכל משימה בתור יהיה כרטיס נפרד",
"Show Working Node Name": "הצג שם צומת פעיל",
"Shows the name of the node which is currently in the process": "מציג את שם הצומת המעובד כעת",
"Auto Clear Interrupted": "ניקוי אוטומטי של משימות שהופסקו",
"Auto-clear cancelled & failed jobs on new generation": "נקה אוטומטית משימות שבוטלו או נכשלו בעת יצירה חדשה",
"Permanent Delete on Disk Deletion": "מחיקה לצמיתות מהדיסק",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "מחק קבצים לצמיתות בעת מחיקה (עוקף את סל המיחזור)",
"Hide Tab: {tab}": "הסתר לשונית: {tab}",
"Assets": "נכסים",
"Nodes": "צמתים",
"Models": "מודלים",
"Workflows": "תהליכי עבודה",
"Apps": "יישומים",
"Templates": "תבניות",
"Override Stock History": "החלף היסטוריית ברירת מחדל",
"Replace the stock Job History sidebar with Comfy Queue": "מחליף את סרגל הצד של היסטוריית העבודות הרגילה ב-Comfy Queue",
"Hide Graph Button": "הסתר כפתור 'גרף'",
"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": "פריסה קומפקטית",
"Hide Junk": "הסתר פריטים מיותרים",
"Default": "ברירת מחדל",
"Dock": "הצמד",
"Hide": "הסתר",
"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 פועלים גם כשפריסה אחרת פעילה. ההקלדה בשדות טקסט אינה מושפעת."
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "Ripristina visuale",
"Settings": "Impostazioni",
"Export As...": "Esporta come...",
"Failed to load 3D format": "Caricamento formato 3D non riuscito"
"Failed to load 3D format": "Caricamento formato 3D non riuscito",
"Toggle Comfy Queue Sidebar": "Attiva/Disattiva barra laterale Comfy Queue",
"Toggle Ignore Selected Node(s) in Queue": "Ignora/Attiva nodi selezionati nella coda",
"Renumber All Nodes (Execution Order)": "Rinumera tutti i nodi (ordine di esecuzione)",
"Set Selected Node ID...": "Imposta ID nodo selezionato...",
"Set Node ID...": "Imposta ID nodo...",
"Gallery": "Galleria",
"Gallery (Outputs)": "Galleria (Output)",
"Go Up One Level": "Sali di un livello",
"Open Current Folder in File Manager": "Apri cartella corrente in Esplora file",
"Refresh Gallery": "Aggiorna galleria",
"Filter gallery files...": "Filtra file della galleria...",
"Loading files...": "Caricamento file...",
"No files found in this folder.": "Nessun file trovato in questa cartella.",
"Failed to load gallery from disk.": "Impossibile caricare la galleria dal disco.",
"Sort: Newest First": "Ordina: Più recenti prima",
"Sort: Oldest First": "Ordina: Più vecchi prima",
"Search Gallery (Filename or Prompt)": "Cerca nella galleria (nome file o prompt)",
"Search filenames or prompt metadata...": "Cerca nomi di file o metadati del prompt..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "Soglia colonne coda",
"Width Threshold for Queue Columns (px)": "Larghezza in pixel per dividere la coda in più colonne",
"Show Pending Count Only": "Mostra solo conteggio in attesa",
"If disabled, each queued job will have a separate individual card": "Se disabilitato, ogni task in coda avrà una scheda separata",
"Show Working Node Name": "Mostra nome nodo in elaborazione",
"Shows the name of the node which is currently in the process": "Mostra il nome del nodo attualmente in elaborazione",
"Auto Clear Interrupted": "Pulizia automatica interrotti",
"Auto-clear cancelled & failed jobs on new generation": "Cancella automaticamente i task annullati e falliti a ogni nuova generazione",
"Permanent Delete on Disk Deletion": "Eliminazione definitiva dal disco",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "Elimina definitivamente i file al momento della cancellazione (senza passare dal cestino)",
"Hide Tab: {tab}": "Nascondi scheda: {tab}",
"Assets": "Risorse",
"Nodes": "Nodi",
"Models": "Modelli",
"Workflows": "Flussi di lavoro",
"Apps": "Applicazioni",
"Templates": "Modelli predefiniti",
"Override Stock History": "Sostituisci cronologia standard",
"Replace the stock Job History sidebar with Comfy Queue": "Sostituisce la barra laterale della cronologia standard con Comfy Queue",
"Hide Graph Button": "Nascondi pulsante 'Grafo'",
"Hide floating 'Graph' (Workflow/Node Map) button": "Nasconde il pulsante mobile 'Grafo' (mappa dei nodi)",
"Hide Avatar": "Nascondi avatar",
"Floating Canvas Controls": "Controlli mobili dell'area di lavoro",
"Classic Layout": "Layout classico",
"Places the controls and the open workflow tabs on a single unified top bar": "Posiziona i controlli e le schede aperte su una barra superiore unificata",
"Comfy Layout": "Layout compatto",
"Hide Junk": "Nascondi elementi inutili",
"Default": "Predefinito",
"Dock": "Aggancia",
"Hide": "Nascondi",
"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."
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "視点をリセット",
"Settings": "設定",
"Export As...": "エクスポート...",
"Failed to load 3D format": "3D形式の読み込みに失敗しました"
"Failed to load 3D format": "3D形式の読み込みに失敗しました",
"Toggle Comfy Queue Sidebar": "Comfy Queue サイドバーの切り替え",
"Toggle Ignore Selected Node(s) in Queue": "キューで選択したノードを無視/解除",
"Renumber All Nodes (Execution Order)": "すべてのノード番号を再割り当て (実行順)",
"Set Selected Node ID...": "選択したノードIDを設定...",
"Set Node ID...": "ノードIDを設定...",
"Gallery": "ギャラリー",
"Gallery (Outputs)": "ギャラリー (出力)",
"Go Up One Level": "1つ上の階層へ",
"Open Current Folder in File Manager": "現在のフォルダーをファイルマネージャーで開く",
"Refresh Gallery": "ギャラリーを更新",
"Filter gallery files...": "ギャラリーファイルを絞り込み...",
"Loading files...": "ファイルを読み込み中...",
"No files found in this folder.": "このフォルダーにはファイルがありません。",
"Failed to load gallery from disk.": "ディスクからギャラリーを読み込めませんでした。",
"Sort: Newest First": "並び替え:新しい順",
"Sort: Oldest First": "並び替え:古い順",
"Search Gallery (Filename or Prompt)": "ギャラリーを検索 (ファイル名またはプロンプト)",
"Search filenames or prompt metadata...": "ファイル名またはプロンプトを検索..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "列分割の幅しきい値",
"Width Threshold for Queue Columns (px)": "キューを複数列に分割する幅のしきい値 (px)",
"Show Pending Count Only": "待機数のみ表示",
"If disabled, each queued job will have a separate individual card": "無効にすると、キュー内の各タスクが個別のカードとして表示されます",
"Show Working Node Name": "処理中のノード名を表示",
"Shows the name of the node which is currently in the process": "現在処理されているノードの名前を表示します",
"Auto Clear Interrupted": "中断タスクの自動クリア",
"Auto-clear cancelled & failed jobs on new generation": "新規生成時にキャンセルおよび失敗したタスクを自動的に消去します",
"Permanent Delete on Disk Deletion": "ディスクから完全消去",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "ディスクからの削除時にゴミ箱を経由せず完全に消去します",
"Hide Tab: {tab}": "タブを非表示: {tab}",
"Assets": "アセット",
"Nodes": "ノード",
"Models": "モデル",
"Workflows": "ワークフロー",
"Apps": "アプリ",
"Templates": "テンプレート",
"Override Stock History": "標準の履歴を置き換える",
"Replace the stock Job History sidebar with Comfy Queue": "標準の履歴サイドバーを Comfy Queue に置き換えます",
"Hide Graph Button": "「グラフ」ボタンを非表示",
"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": "コンパクトレイアウト",
"Hide Junk": "不要な項目を非表示",
"Default": "デフォルト",
"Dock": "ドック",
"Hide": "非表示",
"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 などのショートカットが動作するようにします。テキスト入力欄での入力には影響しません。"
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "시점 초기화",
"Settings": "설정",
"Export As...": "내보내기...",
"Failed to load 3D format": "3D 형식 로드 실패"
"Failed to load 3D format": "3D 형식 로드 실패",
"Toggle Comfy Queue Sidebar": "Comfy Queue 사이드바 전환",
"Toggle Ignore Selected Node(s) in Queue": "대기열에서 선택한 노드 무시/해제",
"Renumber All Nodes (Execution Order)": "모든 노드 재번호 매기기 (실행 순서)",
"Set Selected Node ID...": "선택한 노드 ID 설정...",
"Set Node ID...": "노드 ID 설정...",
"Gallery": "갤러리",
"Gallery (Outputs)": "갤러리 (출력)",
"Go Up One Level": "한 수준 위로",
"Open Current Folder in File Manager": "파일 탐색기에서 현재 폴더 열기",
"Refresh Gallery": "갤러리 새로고침",
"Filter gallery files...": "갤러리 파일 필터링...",
"Loading files...": "파일 로딩 중...",
"No files found in this folder.": "이 폴더에 파일이 없습니다.",
"Failed to load gallery from disk.": "디스크에서 갤러리를 불러오지 못했습니다.",
"Sort: Newest First": "정렬: 최신순",
"Sort: Oldest First": "정렬: 오래된순",
"Search Gallery (Filename or Prompt)": "갤러리 검색 (파일명 또는 프롬프트)",
"Search filenames or prompt metadata...": "파일명 또는 프롬프트 메타데이터 검색..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "열 분할 너비 기준값",
"Width Threshold for Queue Columns (px)": "대기열을 여러 열로 나눌 너비 기준값 (px)",
"Show Pending Count Only": "대기 수량만 표시",
"If disabled, each queued job will have a separate individual card": "비활성화 시 대기열의 각 작업이 개별 카드로 표시됩니다",
"Show Working Node Name": "진행 중인 노드 이름 표시",
"Shows the name of the node which is currently in the process": "현재 처리 중인 노드의 이름을 표시합니다",
"Auto Clear Interrupted": "중단된 작업 자동 삭제",
"Auto-clear cancelled & failed jobs on new generation": "새 생성 시 취소되거나 실패한 작업을 자동으로 삭제합니다",
"Permanent Delete on Disk Deletion": "디스크에서 완전 삭제",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "디스크에서 삭제 시 휴지통을 거치지 않고 영구 삭제합니다",
"Hide Tab: {tab}": "탭 숨기기: {tab}",
"Assets": "에셋",
"Nodes": "노드",
"Models": "모델",
"Workflows": "워크플로",
"Apps": "앱",
"Templates": "템플릿",
"Override Stock History": "기본 작업 기록 대체",
"Replace the stock Job History sidebar with Comfy Queue": "기본 작업 기록 사이드바를 Comfy Queue로 대체합니다",
"Hide Graph Button": "'그래프' 버튼 숨기기",
"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": "컴팩트 레이아웃",
"Hide Junk": "불필요한 항목 숨기기",
"Default": "기본값",
"Dock": "도킹",
"Hide": "숨기기",
"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 같은 단축키가 작동합니다. 텍스트 입력란에서의 입력에는 영향을 주지 않습니다."
}
@@ -96,5 +96,23 @@
"Reset View": "Redefinir visão",
"Settings": "Configurações",
"Export As...": "Exportar como...",
"Failed to load 3D format": "Falha ao carregar formato 3D"
"Failed to load 3D format": "Falha ao carregar formato 3D",
"Toggle Comfy Queue Sidebar": "Alternar barra lateral Comfy Queue",
"Toggle Ignore Selected Node(s) in Queue": "Ignorar/Incluir nós selecionados na fila",
"Renumber All Nodes (Execution Order)": "Renumerar todos os nós (ordem de execução)",
"Set Selected Node ID...": "Definir ID do nó selecionado...",
"Set Node ID...": "Definir ID do nó...",
"Gallery": "Galeria",
"Gallery (Outputs)": "Galeria (Saídas)",
"Go Up One Level": "Subir um nível",
"Open Current Folder in File Manager": "Abrir pasta atual no gerenciador de arquivos",
"Refresh Gallery": "Atualizar galeria",
"Filter gallery files...": "Filtrar arquivos da galeria...",
"Loading files...": "Carregando arquivos...",
"No files found in this folder.": "Nenhum arquivo encontrado nesta pasta.",
"Failed to load gallery from disk.": "Falha ao carregar a galeria do disco.",
"Sort: Newest First": "Ordenar: Mais recentes primeiro",
"Sort: Oldest First": "Ordenar: Mais antigos primeiro",
"Search Gallery (Filename or Prompt)": "Pesquisar na galeria (nome do arquivo ou prompt)",
"Search filenames or prompt metadata...": "Pesquisar nomes de arquivos ou metadados de prompt..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "Limite de colunas da fila",
"Width Threshold for Queue Columns (px)": "Largura em pixels para dividir a fila em várias colunas",
"Show Pending Count Only": "Mostrar apenas contagem pendente",
"If disabled, each queued job will have a separate individual card": "Se desativado, cada tarefa na fila terá um cartão individual",
"Show Working Node Name": "Mostrar nome do nó em execução",
"Shows the name of the node which is currently in the process": "Mostra o nome do nó que está sendo processado no momento",
"Auto Clear Interrupted": "Limpeza automática de interrompidos",
"Auto-clear cancelled & failed jobs on new generation": "Limpar automaticamente tarefas canceladas e com falha ao iniciar nova geração",
"Permanent Delete on Disk Deletion": "Exclusão permanente do disco",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "Excluir arquivos permanentemente (sem passar pela Lixeira)",
"Hide Tab: {tab}": "Ocultar aba: {tab}",
"Assets": "Recursos",
"Nodes": "Nós",
"Models": "Modelos",
"Workflows": "Fluxos de trabalho",
"Apps": "Aplicativos",
"Templates": "Modelos prévios",
"Override Stock History": "Substituir histórico padrão",
"Replace the stock Job History sidebar with Comfy Queue": "Substitui a barra lateral de histórico padrão pelo Comfy Queue",
"Hide Graph Button": "Ocultar botão 'Grafo'",
"Hide floating 'Graph' (Workflow/Node Map) button": "Oculta o botão flutuante 'Grafo' (mapa de nós)",
"Hide Avatar": "Ocultar avatar",
"Floating Canvas Controls": "Controles flutuantes da tela",
"Classic Layout": "Layout clássico",
"Places the controls and the open workflow tabs on a single unified top bar": "Posiciona os controles e as abas abertas em uma barra superior unificada",
"Comfy Layout": "Layout compacto",
"Hide Junk": "Ocultar itens desnecessários",
"Default": "Padrão",
"Dock": "Fixar",
"Hide": "Ocultar",
"Enable Output Gallery Sidebar": "Ativar barra lateral da galeria de saídas",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Corrigir atalhos não ASCII (atalhos em qualquer layout de teclado)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Trata letras não latinas (por exemplo, cirílico) como a tecla latina na mesma posição física, para que atalhos como Q ou Ctrl+Z funcionem com outro layout ativo. A digitação em campos de texto não é afetada."
}
+22 -4
View File
@@ -33,7 +33,7 @@
"Remove from batch (Hold Ctrl to delete file from disk)": "Удалить из пакета (Ctrl для удаления с диска)",
"Click again to confirm removing from batch": "Нажмите еще раз для удаления из пакета",
"Delete node output (Hold Ctrl to delete file from disk)": "Удалить результат ноды (Ctrl для удаления с диска)",
"Ctrl+Click again to delete this node's files from DISK / TRASH": "Ctrl+Клик еще раз для удаления с ДИСКА",
"Ctrl+Click again to delete this node's files from DISK / TRASH": "Ctrl+Клик еще раз для удаления файлов с ДИСКА",
"Click again to confirm removing output": "Нажмите еще раз для подтверждения удаления",
"Moved file to Recycle Bin.": "Файл перемещен в корзину.",
"Permanently deleted file.": "Файл удален навсегда.",
@@ -59,12 +59,12 @@
"Please enter a valid positive number.": "Пожалуйста, введите положительное число.",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "ID ноды #{id} уже используется нодой \"{name}\". Поменять их местами?",
"Assigned node ID #{id} to \"{name}\".": "Ноде \"{name}\" присвоен ID #{id}.",
"Failed to set node ID: ": "Не удалось установить ID ноды: " ,
"Failed to set node ID: ": "Не удалось установить ID ноды: ",
"Video playback. Press Esc to close.": "Воспроизведение видео. Нажмите Esc для закрытия.",
"Click image to zoom (100%/Fit) | Shift+Click another card to compare.": "Клик для масштабирования (100%/Вписать) | Shift+Клик по другой карточке для сравнения.",
"Toggle Loop": "Зациклить",
"Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.": "Несовпадение пропорций ({wA}x{hA} против {wB}x{hB}). Изображения должны иметь одинаковые пропорции для сравнения.",
"Click image to zoom | Drag image to compare slider | Esc to close.": "Клик для масштабирования | Перетащите для сдвига слайдера | Esc для закрытия.",
"Click image to zoom | Drag image to compare slider | Esc to close.": "Клик для масштабирования | Перетащите слайдер для сравнения | Esc для закрытия.",
"Copy": "Копировать",
"Copied!": "Скопировано!",
"Text Output": "Текстовый результат",
@@ -96,5 +96,23 @@
"Reset View": "Сброс вида",
"Settings": "Настройки",
"Export As...": "Экспорт...",
"Failed to load 3D format": "Не удалось загрузить 3D-формат"
"Failed to load 3D format": "Не удалось загрузить 3D-формат",
"Toggle Comfy Queue Sidebar": "Переключить боковую панель Comfy Queue",
"Toggle Ignore Selected Node(s) in Queue": "Игнорировать выбранные ноды в очереди",
"Renumber All Nodes (Execution Order)": "Перенумеровать ноды (порядок выполнения)",
"Set Selected Node ID...": "Задать ID выбранной ноды...",
"Set Node ID...": "Задать ID ноды...",
"Gallery": "Галерея",
"Gallery (Outputs)": "Галерея (Результаты)",
"Go Up One Level": "На один уровень вверх",
"Open Current Folder in File Manager": "Открыть текущую папку в проводнике",
"Refresh Gallery": "Обновить галерею",
"Filter gallery files...": "Фильтр файлов галереи...",
"Loading files...": "Загрузка файлов...",
"No files found in this folder.": "В этой папке нет файлов.",
"Failed to load gallery from disk.": "Не удалось загрузить галерею с диска.",
"Sort: Newest First": "Сортировка: сначала новые",
"Sort: Oldest First": "Сортировка: сначала старые",
"Search Gallery (Filename or Prompt)": "Поиск по галерее (имя файла или промпт)",
"Search filenames or prompt metadata...": "Поиск по именам файлов или промптам..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "Порог колонок очереди",
"Width Threshold for Queue Columns (px)": "Ширина в пикселях для разделения очереди на несколько колонок",
"Show Pending Count Only": "Только количество в очереди",
"If disabled, each queued job will have a separate individual card": "Если выключено, каждая задача в очереди будет отдельной карточкой",
"Show Working Node Name": "Имя выполняющейся ноды",
"Shows the name of the node which is currently in the process": "Показывать имя ноды, выполняющейся в данный момент",
"Auto Clear Interrupted": "Автоочистка отменённых",
"Auto-clear cancelled & failed jobs on new generation": "Автоматически очищать отменённые и неудачные задачи при новой генерации",
"Permanent Delete on Disk Deletion": "Безвозвратное удаление с диска",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "Удалять файлы навсегда при удалении с диска (минуя корзину)",
"Hide Tab: {tab}": "Скрыть вкладку: {tab}",
"Assets": "Ресурсы",
"Nodes": "Ноды",
"Models": "Модели",
"Workflows": "Рабочие процессы",
"Apps": "Приложения",
"Templates": "Шаблоны",
"Override Stock History": "Замена стандартной истории",
"Replace the stock Job History sidebar with Comfy Queue": "Заменить стандартную панель истории на Comfy Queue",
"Hide Graph Button": "Скрыть кнопку 'Граф'",
"Hide floating 'Graph' (Workflow/Node Map) button": "Скрыть плавающую кнопку 'Граф' (карта нод)",
"Hide Avatar": "Скрыть аватар",
"Floating Canvas Controls": "Плавающие элементы управления",
"Places the controls and the open workflow tabs on a single unified top bar": "Разместить элементы управления и открытые вкладки на единой верхней панели",
"Comfy Layout": "Компактный интерфейс",
"Classic Layout": "Компактный интерфейс",
"Hide Junk": "Скрыть лишнее",
"Default": "По умолчанию",
"Dock": "Закрепить",
"Hide": "Скрыть",
"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 работают при любой активной раскладке. Ввод текста в полях не затрагивается."
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "Görünümü sıfırla",
"Settings": "Ayarlar",
"Export As...": "Farklı dışa aktar...",
"Failed to load 3D format": "3D formatı yüklenemedi"
"Failed to load 3D format": "3D formatı yüklenemedi",
"Toggle Comfy Queue Sidebar": "Comfy Queue Kenar Çubuğunu Aç/Kapat",
"Toggle Ignore Selected Node(s) in Queue": "Kuyrukta Seçili Düğümleri Yoksay/Etkinleştir",
"Renumber All Nodes (Execution Order)": "Tüm Düğümleri Yeniden Numaralandır (Çalıştırma Sırası)",
"Set Selected Node ID...": "Seçili Düğüm Kimliğini Belirle...",
"Set Node ID...": "Düğüm Kimliğini Belirle...",
"Gallery": "Galeri",
"Gallery (Outputs)": "Galeri (Çıktılar)",
"Go Up One Level": "Bir Düzey Yukarı Çık",
"Open Current Folder in File Manager": "Mevcut Klasörü Dosya Yöneticisinde Aç",
"Refresh Gallery": "Galeriyi Yenile",
"Filter gallery files...": "Galeri dosyalarını filtrele...",
"Loading files...": "Dosyalar yükleniyor...",
"No files found in this folder.": "Bu klasörde dosya bulunamadı.",
"Failed to load gallery from disk.": "Galeri diskten yüklenemedi.",
"Sort: Newest First": "Sıralama: Önce En Yeniler",
"Sort: Oldest First": "Sıralama: Önce En Eskiler",
"Search Gallery (Filename or Prompt)": "Galeride Ara (Dosya Adı veya Prompt)",
"Search filenames or prompt metadata...": "Dosya adlarını veya prompt meta verilerini ara..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "Kuyruk sütun genişliği eşiği",
"Width Threshold for Queue Columns (px)": "Kuyruğu birden fazla sütuna bölmek için piksel genişliği",
"Show Pending Count Only": "Yalnızca bekleyen sayısını göster",
"If disabled, each queued job will have a separate individual card": "Devre dışı bırakılırsa kuyruktaki her görev ayrı bir karta sahip olur",
"Show Working Node Name": "Çalışan düğüm adını göster",
"Shows the name of the node which is currently in the process": "Şu anda işlenmekte olan düğümün adını gösterir",
"Auto Clear Interrupted": "Kesilenleri otomatik temizle",
"Auto-clear cancelled & failed jobs on new generation": "Yeni üretimde iptal edilen ve başarısız olan görevleri otomatik olarak temizle",
"Permanent Delete on Disk Deletion": "Diskten kalıcı silme",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "Diskten silerken dosyaları kalıcı olarak sil (Geri Dönüşüm Kutusu'nu atlar)",
"Hide Tab: {tab}": "Sekmeyi gizle: {tab}",
"Assets": "Varlıklar",
"Nodes": "Düğümler",
"Models": "Modeller",
"Workflows": "İş Akışları",
"Apps": "Uygulamalar",
"Templates": "Şablonlar",
"Override Stock History": "Varsayılan geçmişi değiştir",
"Replace the stock Job History sidebar with Comfy Queue": "Varsayılan Görev Geçmişi kenar çubuğunu Comfy Queue ile değiştirir",
"Hide Graph Button": "'Grafik' düğmesini gizle",
"Hide floating 'Graph' (Workflow/Node Map) button": "Kayan 'Grafik' (İş akışı haritası) düğmesini gizler",
"Hide Avatar": "Avatarı gizle",
"Floating Canvas Controls": "Kayan tuval kontrolleri",
"Classic Layout": "Klasik düzen",
"Places the controls and the open workflow tabs on a single unified top bar": "Kontrolleri ve açık iş akışı sekmelerini tek bir birleşik üst çubukta toplar",
"Comfy Layout": "Kompakt Düzen",
"Hide Junk": "Gereksiz Öğeleri Gizle",
"Default": "Varsayılan",
"Dock": "Sabitle",
"Hide": "Gizle",
"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."
}
@@ -96,5 +96,23 @@
"Reset View": "重設視角",
"Settings": "設定",
"Export As...": "匯出為...",
"Failed to load 3D format": "載入 3D 格式失敗"
"Failed to load 3D format": "載入 3D 格式失敗",
"Toggle Comfy Queue Sidebar": "切換 Comfy Queue 側邊欄",
"Toggle Ignore Selected Node(s) in Queue": "在隊列中忽略選取節點",
"Renumber All Nodes (Execution Order)": "重新編號所有節點 (按執行順序)",
"Set Selected Node ID...": "設定選取節點 ID...",
"Set Node ID...": "設定節點 ID...",
"Gallery": "圖庫",
"Gallery (Outputs)": "圖庫 (輸出)",
"Go Up One Level": "返回上一層",
"Open Current Folder in File Manager": "在檔案管理器中開啟目前資料夾",
"Refresh Gallery": "重新整理圖庫",
"Filter gallery files...": "篩選圖庫檔案...",
"Loading files...": "正在載入檔案...",
"No files found in this folder.": "此資料夾中未找到檔案。",
"Failed to load gallery from disk.": "從磁碟載入圖庫失敗。",
"Sort: Newest First": "排序:最新優先",
"Sort: Oldest First": "排序:最舊優先",
"Search Gallery (Filename or Prompt)": "搜尋圖庫 (檔案名稱或提示詞)",
"Search filenames or prompt metadata...": "搜尋檔案名稱或提示詞元資料..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "隊列欄寬閾值",
"Width Threshold for Queue Columns (px)": "隊列顯示多欄的寬度閾值 (像素)",
"Show Pending Count Only": "僅顯示等待數量",
"If disabled, each queued job will have a separate individual card": "若停用,每個排隊任務將顯示為獨立卡片",
"Show Working Node Name": "顯示執行中節點名稱",
"Shows the name of the node which is currently in the process": "顯示目前正在處理的節點名稱",
"Auto Clear Interrupted": "自動清除中斷任務",
"Auto-clear cancelled & failed jobs on new generation": "新生成時自動清除已取消與失敗的任務",
"Permanent Delete on Disk Deletion": "從磁碟永久刪除",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "從磁碟刪除時永久刪除檔案 (略過資源回收筒)",
"Hide Tab: {tab}": "隱藏標籤頁: {tab}",
"Assets": "資產",
"Nodes": "節點",
"Models": "模型",
"Workflows": "工作流",
"Apps": "應用",
"Templates": "範本",
"Override Stock History": "取代原生任務歷史",
"Replace the stock Job History sidebar with Comfy Queue": "使用 Comfy Queue 取代原生任務歷史側邊欄",
"Hide Graph Button": "隱藏「圖」按鈕",
"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": "緊湊版面配置",
"Hide Junk": "隱藏雜項",
"Default": "預設",
"Dock": "停靠",
"Hide": "隱藏",
"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 等快速鍵也能正常運作。在文字欄位中輸入不受影響。"
}
+19 -1
View File
@@ -96,5 +96,23 @@
"Reset View": "重置视角",
"Settings": "设置",
"Export As...": "导出为...",
"Failed to load 3D format": "加载 3D 格式失败"
"Failed to load 3D format": "加载 3D 格式失败",
"Toggle Comfy Queue Sidebar": "切换 Comfy Queue 侧边栏",
"Toggle Ignore Selected Node(s) in Queue": "在队列中忽略所选节点",
"Renumber All Nodes (Execution Order)": "重新编号所有节点 (按执行顺序)",
"Set Selected Node ID...": "设置所选节点 ID...",
"Set Node ID...": "设置节点 ID...",
"Gallery": "图库",
"Gallery (Outputs)": "图库 (输出)",
"Go Up One Level": "返回上一级",
"Open Current Folder in File Manager": "在文件管理器中打开当前文件夹",
"Refresh Gallery": "刷新图库",
"Filter gallery files...": "筛选图库文件...",
"Loading files...": "正在加载文件...",
"No files found in this folder.": "此文件夹中未找到文件。",
"Failed to load gallery from disk.": "从磁盘加载图库失败。",
"Sort: Newest First": "排序:最新优先",
"Sort: Oldest First": "排序:最早优先",
"Search Gallery (Filename or Prompt)": "搜索图库 (文件名或提示词)",
"Search filenames or prompt metadata...": "搜索文件名或提示词元数据..."
}
+35
View File
@@ -0,0 +1,35 @@
{
"Grid Columns Threshold": "队列列宽阈值",
"Width Threshold for Queue Columns (px)": "队列显示多列的宽度阈值 (像素)",
"Show Pending Count Only": "仅显示等待数量",
"If disabled, each queued job will have a separate individual card": "如果禁用,每个排队的任务将拥有单独的卡片",
"Show Working Node Name": "显示正在执行的节点名称",
"Shows the name of the node which is currently in the process": "显示当前正在处理的节点名称",
"Auto Clear Interrupted": "自动清除中断任务",
"Auto-clear cancelled & failed jobs on new generation": "新生成时自动清除已取消和失败的任务",
"Permanent Delete on Disk Deletion": "从磁盘永久删除",
"Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)": "从磁盘删除时永久删除文件 (绕过回收站)",
"Hide Tab: {tab}": "隐藏标签页: {tab}",
"Assets": "资产",
"Nodes": "节点",
"Models": "模型",
"Workflows": "工作流",
"Apps": "应用",
"Templates": "模板",
"Override Stock History": "替换原生任务历史",
"Replace the stock Job History sidebar with Comfy Queue": "使用 Comfy Queue 替换原生任务历史侧边栏",
"Hide Graph Button": "隐藏'图'按钮",
"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": "紧凑布局",
"Hide Junk": "隐藏杂项",
"Default": "默认",
"Dock": "停靠",
"Hide": "隐藏",
"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) {
+228 -154
View File
@@ -2,126 +2,25 @@ 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 } 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";
import { setupExecutionTracker } from "./queue/executionTracker.js";
import { setupSidebarView, renderSidebar, teardownSidebarView } from "./ui/sidebarView.js";
import { setupGalleryView, teardownGalleryView } from "./ui/galleryView.js";
import { initI18n, t } from "./core/i18n.js";
import { updateCardProgressTargeted, cardPool } from "./ui/cardRenderer.js";
import { 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 registerSettings() {
app.ui.settings.addSetting({
id: SettingIds.GRID_COLUMNS_THRESHOLD,
name: "Width Threshold for Queue Columns (px)",
type: "number",
defaultValue: 350
});
app.ui.settings.addSetting({
id: SettingIds.SHOW_PENDING_COUNT_ONLY,
name: "If disabled, each queued job will have a separate individual card",
type: "boolean",
defaultValue: true
});
app.ui.settings.addSetting({
id: SettingIds.SHOW_WORKING_NODE_NAME,
name: "Shows the name of the node which is currently in the process",
type: "boolean",
defaultValue: true
});
app.ui.settings.addSetting({
id: SettingIds.AUTO_CLEAR_INTERRUPTED,
name: "Auto-clear cancelled & failed jobs on new generation",
type: "boolean",
defaultValue: false
});
app.ui.settings.addSetting({
id: "Comfy Sidebar.Permanent Delete on Disk Deletion",
name: "Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)",
type: "boolean",
defaultValue: false
});
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
sidebarTabs.forEach(tab => {
app.ui.settings.addSetting({
id: `Comfy Sidebar.Hide Junk.${tab}`,
name: `Hide Tab: ${tab}`,
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
});
app.ui.settings.addSetting({
id: SettingIds.OVERRIDE_STOCK_HISTORY,
name: "Replace the stock Job History sidebar with Comfy Queue",
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (isInitialized && app.ui?.settings) {
syncStockHistoryAndProgressSettings(value);
}
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: SettingIds.HIDE_GRAPH_BUTTON,
name: "Hide floating 'Graph' (Workflow/Node Map) button",
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: "Comfy Sidebar.Hide Junk.Avatar",
name: "Hide Avatar",
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: "Comfy Sidebar.Hide Junk.Floating Canvas Controls",
name: "Floating Canvas Controls",
type: "combo",
options: ["Default", "Dock", "Hide"],
defaultValue: "Default",
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: SettingIds.CLASSIC_LAYOUT,
name: "Places the controls and the open workflow tabs on a single unified top bar",
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (isInitialized) {
applyClassicLayout(value, true);
}
}
});
}
function setupKeydownShortcuts() {
activeKeydownHandler = (e) => {
const activeEl = document.activeElement;
@@ -132,13 +31,7 @@ function setupKeydownShortcuts() {
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();
}
// Ctrl+Q / Cmd+Q: Toggle Ignore Selected Node
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
@@ -156,6 +49,150 @@ function setupKeydownShortcuts() {
document.addEventListener("keydown", activeKeydownHandler, true);
}
function registerSettings() {
app.ui.settings.addSetting({
id: SettingIds.GRID_COLUMNS_THRESHOLD,
name: t("Width Threshold for Queue Columns (px)"),
type: "number",
defaultValue: 350
});
app.ui.settings.addSetting({
id: SettingIds.SHOW_PENDING_COUNT_ONLY,
name: t("If disabled, each queued job will have a separate individual card"),
type: "boolean",
defaultValue: true
});
app.ui.settings.addSetting({
id: SettingIds.SHOW_WORKING_NODE_NAME,
name: t("Shows the name of the node which is currently in the process"),
type: "boolean",
defaultValue: true
});
app.ui.settings.addSetting({
id: SettingIds.AUTO_CLEAR_INTERRUPTED,
name: t("Auto-clear cancelled & failed jobs on new generation"),
type: "boolean",
defaultValue: false
});
const setGalleryButtonVisible = (visible) => {
try {
const buttons = document.querySelectorAll(".side-bar-button, [data-tab-id]");
for (const btn of buttons) {
if (btn.getAttribute("data-tab-id") === "classic-comfy-gallery" ||
btn.getAttribute("title")?.includes("Gallery") ||
btn.querySelector(".pi-folder")) {
btn.style.display = visible ? "" : "none";
break;
}
}
} catch (e) {
console.warn("[ComfySidebar] Error toggling gallery tab button:", e);
}
};
app.ui.settings.addSetting({
id: "Comfy Sidebar.Enable Gallery",
name: t("Enable Output Gallery Sidebar"),
type: "boolean",
defaultValue: true,
onChange: (val) => {
setGalleryButtonVisible(val ?? true);
}
});
app.ui.settings.addSetting({
id: "Comfy Sidebar.Permanent Delete on Disk Deletion",
name: t("Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)"),
type: "boolean",
defaultValue: false
});
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
sidebarTabs.forEach(tab => {
app.ui.settings.addSetting({
id: `Comfy Sidebar.Hide Junk.${tab}`,
name: t("Hide Tab: {tab}", { tab: t(tab) }),
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
});
app.ui.settings.addSetting({
id: SettingIds.OVERRIDE_STOCK_HISTORY,
name: t("Replace the stock Job History sidebar with Comfy Queue"),
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (isInitialized && app.ui?.settings) {
syncStockHistoryAndProgressSettings(value);
}
applySidebarOverride();
setTimeout(() => {
applySidebarOverride();
syncClassicLayout();
}, 50);
}
});
app.ui.settings.addSetting({
id: SettingIds.HIDE_GRAPH_BUTTON,
name: t("Hide floating 'Graph' (Workflow/Node Map) button"),
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: "Comfy Sidebar.Hide Junk.Avatar",
name: t("Hide Avatar"),
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: "Comfy Sidebar.Hide Junk.Floating Canvas Controls",
name: t("Floating Canvas Controls"),
type: "combo",
options: ["Default", "Dock", "Hide"],
defaultValue: "Default",
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
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"),
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (isInitialized) {
applyClassicLayout(value, true);
}
}
});
}
function setupWorkflowTabsPositionWatcher() {
let isSyncing = false;
const TARGET_SETTING = "Comfy.Workflow.WorkflowTabsPosition";
@@ -163,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;
@@ -182,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;
@@ -197,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) => {
@@ -227,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) => {
@@ -258,32 +285,33 @@ function setupWorkflowTabsPositionWatcher() {
app.registerExtension({
name: "ComfySidebar.ClassicRestore",
init() {
async init() {
await initI18n();
registerSettings();
},
// Plain Q toggles the Queue tab through the frontend keybinding system (rebindable in Settings > Keybinding).
keybindings: [
{
combo: { key: "q" },
commandId: "Workspace.ToggleSidebarTab.classic-comfy-sidebar"
}
],
commands: [
{
id: "ComfySidebar.ToggleSidebar",
label: "Toggle Comfy Queue Sidebar",
function: () => {
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
},
{
id: "ComfySidebar.ToggleIgnoreNode",
label: "Toggle Ignore Selected Node(s) in Queue",
label: t("Toggle Ignore Selected Node(s) in Queue"),
function: () => toggleIgnoreActiveNode(() => renderSidebar())
},
{
id: "ComfySidebar.RenumberNodes",
label: "Renumber All Nodes (Execution Order)",
label: t("Renumber All Nodes (Execution Order)"),
function: () => renumberNodesTopologically()
},
{
id: "ComfySidebar.SetNodeId",
label: "Set Selected Node ID...",
label: t("Set Selected Node ID..."),
function: () => setManualNodeId()
}
],
@@ -292,7 +320,7 @@ app.registerExtension({
return [
null,
{
content: "Set Node ID...",
content: t("Set Node ID..."),
callback: () => setManualNodeId(node)
}
];
@@ -302,7 +330,7 @@ app.registerExtension({
return [
null,
{
content: "Renumber All Nodes (Execution Order)",
content: t("Renumber All Nodes (Execution Order)"),
callback: () => renumberNodesTopologically()
}
];
@@ -326,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);
}
@@ -354,14 +376,14 @@ app.registerExtension({
cleanupFns.push(setupWorkflowTabsPositionWatcher());
cleanupFns.push(setupDragAndDrop());
cleanupFns.push(setupLatinHotkeys());
cleanupFns.push(setupVueNodeObserver());
// 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();
@@ -375,13 +397,60 @@ app.registerExtension({
syncAllNodeBadges();
let queueScrollPos = 0;
let galleryScrollPos = 0;
let activeSidebarTab = "queue";
app.extensionManager.registerSidebarTab({
id: "classic-comfy-sidebar",
icon: "pi pi-images",
title: t("Queue"),
tooltip: `${t("Queue")} (Q)`,
type: "custom",
render: (el) => { el.appendChild(sidebarContainer); }
render: (el) => {
document.querySelectorAll(".comfy-sidebar-scroll-top-btn").forEach(btn => { btn.style.display = "none"; });
const scrollParent = getScrollContainer(el);
if (activeSidebarTab === "gallery" && scrollParent) {
galleryScrollPos = scrollParent.scrollTop;
}
activeSidebarTab = "queue";
el.replaceChildren(sidebarContainer);
requestAnimationFrame(() => {
const sp = getScrollContainer(el);
if (sp) sp.scrollTop = queueScrollPos;
});
}
});
const galleryContainer = setupGalleryView();
app.extensionManager.registerSidebarTab({
id: "classic-comfy-gallery",
icon: "pi pi-folder",
title: t("Gallery"),
tooltip: t("Gallery (Outputs)"),
type: "custom",
render: (el) => {
document.querySelectorAll(".comfy-sidebar-scroll-top-btn").forEach(btn => { btn.style.display = "none"; });
const scrollParent = getScrollContainer(el);
if (activeSidebarTab === "queue" && scrollParent) {
queueScrollPos = scrollParent.scrollTop;
}
activeSidebarTab = "gallery";
el.replaceChildren(galleryContainer);
requestAnimationFrame(() => {
const sp = getScrollContainer(el);
if (sp) sp.scrollTop = galleryScrollPos;
});
}
});
const isGalleryEnabled = getSetting("Comfy Sidebar.Enable Gallery", true);
if (!isGalleryEnabled) {
setTimeout(() => setGalleryButtonVisible(false), 50);
}
requestAnimationFrame(() => {
applySidebarOverride();
});
},
@@ -391,6 +460,11 @@ app.registerExtension({
}
destroyLayoutFix();
teardownSidebarView();
teardownGalleryView();
if (app.extensionManager?.unregisterSidebarTab) {
app.extensionManager.unregisterSidebarTab("classic-comfy-gallery");
}
if (activeKeydownHandler) {
document.removeEventListener("keydown", activeKeydownHandler, true);
@@ -401,4 +475,4 @@ app.registerExtension({
app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar");
}
}
});
});
+45 -72
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 } 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,71 +495,54 @@ 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; }
};
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;
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = (ev.ctrlKey || ev.metaKey) && cardObj.canDeleteFromDisk !== false;
if (shouldDeleteFromDisk) {
const filesToDelete = [];
const seen = new Set();
const addFile = (item) => {
if (item && item.filename) {
const key = `${item.type || ""}/${item.subfolder || ""}/${item.filename}`;
if (!seen.has(key)) {
seen.add(key);
filesToDelete.push(item);
}
}
};
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();
(liveState.images || []).forEach(addFile);
const allOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
allOutputs.forEach(out => (out.images || []).forEach(addFile));
const liveState = store.getPrompt(state.pid) || state;
if (shouldDeleteFromDisk) {
const filesToDelete = [];
const seen = new Set();
const addFile = (item) => {
if (item && item.filename) {
const key = `${item.type || ""}/${item.subfolder || ""}/${item.filename}`;
if (!seen.has(key)) {
seen.add(key);
filesToDelete.push(item);
let deletedCount = 0;
let lastAction = "trashed";
for (const fileItem of filesToDelete) {
const action = await deleteFileOnServer(fileItem);
if (action) {
deletedCount++;
lastAction = action;
}
}
};
(liveState.images || []).forEach(addFile);
const allOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
allOutputs.forEach(out => (out.images || []).forEach(addFile));
let deletedCount = 0;
let lastAction = "trashed";
for (const fileItem of filesToDelete) {
const action = await deleteFileOnServer(fileItem);
if (action) {
deletedCount++;
lastAction = action;
if (deletedCount > 0) {
const msg = lastAction === "trashed"
? `Moved ${deletedCount} file${deletedCount > 1 ? "s" : ""} to Recycle Bin.`
: `Permanently deleted ${deletedCount} file${deletedCount > 1 ? "s" : ""}.`;
showNotification(msg, "success", "Delete Files");
}
}
if (deletedCount > 0) {
const msg = lastAction === "trashed"
? `Moved ${deletedCount} file${deletedCount > 1 ? "s" : ""} to Recycle Bin.`
: `Permanently deleted ${deletedCount} file${deletedCount > 1 ? "s" : ""}.`;
showNotification(msg, "success", "Delete Files");
}
}
deleteHistoryItem(state.pid);
deleteHistoryItem(state.pid);
});
}
};
});
cardPool.set(state.pid, cardObj);
return cardObj;
@@ -643,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;
}
+742
View File
@@ -0,0 +1,742 @@
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, 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";
let galleryContainerEl = null;
let cardStackEl = null;
let scrollToTopBtnEl = null;
let currentColumnCount = 1;
let currentSubfolder = "";
let currentSearchQuery = "";
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";
try {
sortOrder = localStorage.getItem("ComfySidebar.GallerySortOrder") || "newest";
} catch (e) {}
let cachedFolders = [];
let cachedFiles = [];
let displayedFilesCount = 0;
const BATCH_SIZE = 36;
let isLoading = false;
function formatFileSize(bytes) {
if (!bytes || bytes <= 0) return "";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
function updateScrollTopVisibility() {
if (!galleryContainerEl || !galleryContainerEl.isConnected) {
if (scrollToTopBtnEl) scrollToTopBtnEl.style.display = "none";
return;
}
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";
}
// Handle smart scroll-up reveal for header & navigation
handleHeaderScroll(scrollEl);
if (scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 300) {
loadNextBatch();
}
}
}
function handleHeaderScroll(scrollEl) {
if (!stickyHeaderGroupEl) return;
const currentScroll = scrollEl.scrollTop;
const searchContainer = stickyHeaderGroupEl.querySelector(".comfy-sidebar-search-container");
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 delta = currentScroll - lastScrollTop;
lastScrollTop = currentScroll;
// Near the very top: always keep visible & transparent
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) {
// Scrolling DOWN: hide after small buffer
if (scrollDeltaAccumulator < 0) scrollDeltaAccumulator = 0;
scrollDeltaAccumulator += delta;
if (scrollDeltaAccumulator > 25 && !isHeaderHidden) {
stickyHeaderGroupEl.style.transform = "translateY(-100%)";
isHeaderHidden = true;
}
} else if (delta < 0) {
// Scrolling UP: reveal as soon as user scrolls up a little bit (~15px)
if (scrollDeltaAccumulator > 0) scrollDeltaAccumulator = 0;
scrollDeltaAccumulator += delta;
if (scrollDeltaAccumulator < -15 && isHeaderHidden) {
stickyHeaderGroupEl.style.transform = "translateY(0)";
isHeaderHidden = false;
}
}
}
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()}`, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
cachedFolders = data.folders || [];
cachedFiles = data.files || [];
currentSubfolder = (data.subfolder || "").replace(/\\/g, "/");
applySorting();
displayedFilesCount = 0;
} catch (e) {
if (e?.name === "AbortError") return false;
console.error("[ComfySidebar] Failed to load gallery", e);
showNotification(t("Failed to load gallery from disk."), "error");
} finally {
if (galleryFetchController === controller) {
galleryFetchController = null;
isLoading = false;
}
}
return true;
}
function applySorting() {
if (sortOrder === "newest") {
cachedFiles.sort((a, b) => b.mtime - a.mtime);
} else {
cachedFiles.sort((a, b) => a.mtime - b.mtime);
}
}
function mountGalleryCards(cardStack, folderWrapperEl, cardEls, cols = 1, append = false) {
if (!cardStack) return;
const oldEmpty = cardStack.querySelector(".comfy-sidebar-gallery-empty-notice");
if (oldEmpty) oldEmpty.remove();
if (!append) {
let subfolderArea = cardStack.querySelector(".comfy-sidebar-subfolders-area");
if (!subfolderArea) {
subfolderArea = document.createElement("div");
subfolderArea.className = "comfy-sidebar-subfolders-area";
cardStack.insertBefore(subfolderArea, cardStack.firstChild);
}
subfolderArea.innerHTML = "";
if (folderWrapperEl) {
subfolderArea.appendChild(folderWrapperEl);
subfolderArea.style.display = "block";
} else {
subfolderArea.style.display = "none";
}
}
let columnsWrapper = cardStack.querySelector(".comfy-sidebar-masonry-wrapper");
if (!columnsWrapper) {
columnsWrapper = document.createElement("div");
columnsWrapper.className = "comfy-sidebar-masonry-wrapper";
Object.assign(columnsWrapper.style, { display: "flex", gap: "12px", alignItems: "flex-start", width: "100%" });
cardStack.appendChild(columnsWrapper);
}
if (!append && cardEls.length === 0 && !folderWrapperEl) {
columnsWrapper.innerHTML = "";
const emptyEl = document.createElement("div");
emptyEl.className = "comfy-sidebar-card comfy-sidebar-gallery-empty-notice";
Object.assign(emptyEl.style, {
padding: "24px", textAlign: "center", color: "var(--desc-color, #888)", fontSize: "12px", width: "100%"
});
emptyEl.textContent = isLoading ? t("Loading files...") : t("No files found in this folder.");
cardStack.appendChild(emptyEl);
return;
}
while (columnsWrapper.children.length < cols) {
const colDiv = document.createElement("div");
colDiv.className = "comfy-sidebar-masonry-col";
Object.assign(colDiv.style, { flex: "1", display: "flex", flexDirection: "column", gap: "12px", minWidth: "0" });
columnsWrapper.appendChild(colDiv);
}
while (columnsWrapper.children.length > cols) {
columnsWrapper.removeChild(columnsWrapper.lastChild);
}
const colHeights = Array.from(columnsWrapper.children).map(c => c.scrollHeight || 0);
cardEls.forEach((cardEl) => {
let shortestCol = 0;
for (let c = 1; c < cols; c++) {
if (colHeights[c] < colHeights[shortestCol]) shortestCol = c;
}
columnsWrapper.children[shortestCol].appendChild(cardEl);
const cardH = cardEl.offsetHeight > 0 ? cardEl.offsetHeight : 160;
colHeights[shortestCol] += cardH + 12;
});
}
function createFileCard(file) {
const card = document.createElement("div");
card.className = "comfy-sidebar-card completed";
card.style.position = "relative";
card.style.cursor = "pointer";
const previewUrl = `/view?filename=${encodeURIComponent(file.filename)}&type=output&subfolder=${encodeURIComponent(file.subfolder || "")}`;
const isPng = file.filename.toLowerCase().endsWith(".png");
const grid = document.createElement("div");
grid.style.display = "flex";
grid.style.flexDirection = "column";
grid.style.width = "100%";
const cardObj = {
element: card,
grid: grid,
dimEl: null,
currentImageIndex: 0,
firstImgElement: null
};
const fileAsset = {
filename: file.filename,
subfolder: file.subfolder,
type: "output",
url: previewUrl
};
// Render using Queue's exact media pipeline (Images, Videos, 3D, Audio, Files)
renderCardImages(cardObj, {
pid: `gallery-${file.filename}`,
status: PromptStatus.COMPLETED,
images: [fileAsset],
workflow: null
});
const sizeBadge = document.createElement("div");
sizeBadge.className = "comfy-sidebar-card-timer";
sizeBadge.textContent = formatFileSize(file.size);
sizeBadge.style.display = file.size ? "block" : "none";
const isMedia = isImageFormat(file.filename) || /\.(mp4|webm|mov|mkv|avi)$/i.test(file.filename);
let nameBadge = null;
if (isMedia) {
nameBadge = document.createElement("div");
nameBadge.className = "comfy-sidebar-card-filename-badge";
nameBadge.textContent = file.filename;
nameBadge.title = file.filename;
}
const hoverPanel = document.createElement("div");
hoverPanel.className = "comfy-sidebar-hover-panel";
Object.assign(hoverPanel.style, {
position: "absolute", bottom: "4px", right: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnDownload = document.createElement("span");
btnDownload.className = "pi pi-download comfy-sidebar-card-action-btn";
btnDownload.title = t("Download Object");
btnDownload.onclick = (ev) => {
ev.stopPropagation();
const a = document.createElement("a");
a.href = previewUrl;
a.download = file.filename;
a.click();
};
let btnJson = null;
if (isPng) {
btnJson = document.createElement("span");
btnJson.className = "pi pi-file comfy-sidebar-card-action-btn";
btnJson.title = t("Download JSON");
btnJson.onclick = async (ev) => {
ev.stopPropagation();
const wf = await extractWorkflowFromPng(previewUrl);
if (!wf) {
alert(t("No workflow metadata found in this file."));
return;
}
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";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = t("Delete File");
btnDel.appendChild(btnDelLabel);
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.");
showNotification(msg, "success", t("Delete File"));
cachedFiles = cachedFiles.filter(f => f.filename !== file.filename || f.subfolder !== file.subfolder);
displayedFilesCount = Math.max(0, displayedFilesCount - 1);
animateCardRemoval(card, () => {
card.remove();
if (cachedFiles.length === 0) {
renderGallery();
}
});
}
}
});
hoverPanel.appendChild(btnDownload);
if (btnJson) hoverPanel.appendChild(btnJson);
hoverPanel.appendChild(btnDel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
});
const isImage = isImageFormat(file.filename);
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = t("Copy to Clipboard");
if (!isImage) {
btnCopy.style.display = "none";
} else {
btnCopy.onclick = async (ev) => {
ev.stopPropagation();
const success = await copyImageToClipboard(previewUrl);
if (success) {
btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
btnCopy.style.color = "#4ade80";
setTimeout(() => {
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.style.color = "";
}, 1500);
}
};
}
leftHoverPanel.appendChild(btnCopy);
card.append(grid, sizeBadge);
if (nameBadge) card.append(nameBadge);
card.append(hoverPanel, leftHoverPanel);
return card;
}
function updateBreadcrumbUI() {
if (!galleryContainerEl) return;
const breadcrumbBar = galleryContainerEl.querySelector(".comfy-sidebar-breadcrumb-bar");
if (!breadcrumbBar) return;
breadcrumbBar.innerHTML = "";
const rootBtn = document.createElement("span");
rootBtn.className = "comfy-sidebar-breadcrumb-item";
rootBtn.innerHTML = `<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);
const normalized = (currentSubfolder || "").replace(/\\/g, "/");
if (normalized) {
const parts = normalized.split("/").filter(Boolean);
let accumulated = "";
parts.forEach((part) => {
accumulated = accumulated ? `${accumulated}/${part}` : part;
const targetPath = accumulated;
const sep = document.createElement("span");
sep.className = "comfy-sidebar-breadcrumb-sep";
sep.textContent = "/";
breadcrumbBar.appendChild(sep);
const crumb = document.createElement("span");
crumb.className = "comfy-sidebar-breadcrumb-item";
crumb.textContent = part;
crumb.onclick = () => navigateToSubfolder(targetPath);
breadcrumbBar.appendChild(crumb);
});
}
}
function loadNextBatch() {
if (displayedFilesCount >= cachedFiles.length) return;
const nextFiles = cachedFiles.slice(displayedFilesCount, displayedFilesCount + BATCH_SIZE);
displayedFilesCount += nextFiles.length;
const cardElements = nextFiles.map(file => createFileCard(file));
mountGalleryCards(cardStackEl, null, cardElements, currentColumnCount, true);
}
export function renderGallery() {
if (!galleryContainerEl || !cardStackEl) return;
updateBreadcrumbUI();
let subfolderWrapper = null;
if (cachedFolders.length > 0 && !currentSearchQuery) {
subfolderWrapper = document.createElement("div");
subfolderWrapper.className = "comfy-sidebar-folders-grid";
cachedFolders.forEach(folder => {
const folderCard = document.createElement("div");
folderCard.className = "comfy-sidebar-folder-card";
folderCard.title = folder.name;
const icon = document.createElement("span");
icon.className = "pi pi-folder comfy-sidebar-folder-icon";
const name = document.createElement("span");
name.className = "comfy-sidebar-folder-name";
name.textContent = folder.name;
const ctrlOverlay = document.createElement("div");
ctrlOverlay.className = "comfy-sidebar-ctrl-overlay";
ctrlOverlay.innerHTML = `<i class="pi pi-folder-open"></i><span>${t("Open Folder")}</span>`;
folderCard.appendChild(icon);
folderCard.appendChild(name);
folderCard.appendChild(ctrlOverlay);
folderCard.onclick = async (e) => {
if (e.ctrlKey || e.metaKey) {
e.stopPropagation();
await openFileOrFolder({ subfolder: folder.subfolder, type: "output" });
return;
}
await navigateToSubfolder(folder.subfolder);
};
subfolderWrapper.appendChild(folderCard);
});
}
const threshold = getSetting(SettingIds.GRID_COLUMNS_THRESHOLD, 350);
const containerWidth = galleryContainerEl.clientWidth || 0;
if (containerWidth > 0) {
currentColumnCount = calculateGridColumns(containerWidth, threshold);
}
let columnsWrapper = cardStackEl.querySelector(".comfy-sidebar-masonry-wrapper");
if (columnsWrapper) columnsWrapper.innerHTML = "";
const initialFiles = cachedFiles.slice(0, BATCH_SIZE);
displayedFilesCount = initialFiles.length;
const cardElements = initialFiles.map(file => createFileCard(file));
mountGalleryCards(cardStackEl, subfolderWrapper, cardElements, currentColumnCount, false);
updateScrollTopVisibility();
}
async function navigateToSubfolder(subfolder) {
currentSubfolder = (subfolder || "").replace(/\\/g, "/");
currentSearchQuery = "";
await fetchGalleryData(currentSubfolder);
const scrollEl = getScrollContainer(cardStackEl);
if (scrollEl) scrollEl.scrollTop = 0;
renderGallery();
}
export function setupGalleryView() {
const container = document.createElement("div");
galleryContainerEl = container;
Object.assign(container.style, {
display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box",
background: "transparent",
color: "var(--fg-color, var(--p-text-color, #eee))", position: "relative"
});
const header = document.createElement("div");
header.className = "comfy-sidebar-header-root";
Object.assign(header.style, { position: "relative", marginBottom: "8px", height: "26px", display: "flex", alignItems: "center" });
const standardHeader = document.createElement("div");
Object.assign(standardHeader.style, { display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" });
const titleGroup = document.createElement("div");
Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" });
const searchIcon = document.createElement("span");
searchIcon.className = "pi pi-search comfy-sidebar-icon-btn";
searchIcon.title = t("Search Gallery (Filename or Prompt)");
const title = document.createElement("h3");
title.textContent = t("Gallery");
Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" });
titleGroup.appendChild(searchIcon);
titleGroup.appendChild(title);
standardHeader.appendChild(titleGroup);
const actionsGroup = document.createElement("div");
Object.assign(actionsGroup.style, { display: "flex", gap: "5px", alignItems: "center" });
const createHeaderBtn = (iconClass, tooltip, onClickFn) => {
const btn = document.createElement("button");
btn.className = `${iconClass} comfy-sidebar-header-btn`;
btn.title = tooltip;
btn.onclick = async (ev) => {
ev.stopPropagation();
btn.style.transform = "scale(0.88)";
setTimeout(() => { btn.style.transform = ""; }, 120);
await onClickFn();
};
return btn;
};
const initialSortIcon = sortOrder === "newest" ? "pi-sort-amount-down" : "pi-sort-amount-up";
const initialSortTitle = sortOrder === "newest" ? t("Sort: Newest First") : t("Sort: Oldest First");
const btnSort = createHeaderBtn(`pi ${initialSortIcon}`, initialSortTitle, () => {
sortOrder = sortOrder === "newest" ? "oldest" : "newest";
try { localStorage.setItem("ComfySidebar.GallerySortOrder", sortOrder); } catch (e) {}
btnSort.className = `pi ${sortOrder === "newest" ? "pi-sort-amount-down" : "pi-sort-amount-up"} comfy-sidebar-header-btn`;
btnSort.title = sortOrder === "newest" ? t("Sort: Newest First") : t("Sort: Oldest First");
applySorting();
renderGallery();
});
const btnUp = createHeaderBtn("pi pi-arrow-up", t("Go Up One Level"), async () => {
if (!currentSubfolder) return;
const parts = currentSubfolder.split("/").filter(Boolean);
parts.pop();
await navigateToSubfolder(parts.join("/"));
});
const btnOpenFolder = createHeaderBtn("pi pi-folder-open", t("Open Current Folder in File Manager"), async () => {
await openFileOrFolder({ subfolder: currentSubfolder, type: "output" });
});
const btnRefresh = createHeaderBtn("pi pi-refresh", t("Refresh Gallery"), async () => {
await fetchGalleryData(currentSubfolder, currentSearchQuery);
renderGallery();
});
actionsGroup.append(btnSort, btnUp, btnOpenFolder, btnRefresh);
standardHeader.appendChild(actionsGroup);
const searchContainer = document.createElement("div");
searchContainer.className = "comfy-sidebar-search-container";
Object.assign(searchContainer.style, {
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");
sIcon.className = "pi pi-search";
Object.assign(sIcon.style, { fontSize: "11px", opacity: "0.6", marginRight: "6px", transition: "transform 0.2s" });
const sInput = document.createElement("input");
Object.assign(sInput, { type: "text", placeholder: t("Search filenames or prompt metadata...") });
Object.assign(sInput.style, {
flex: "1", background: "transparent", border: "none", outline: "none",
color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0"
});
const sClear = document.createElement("span");
sClear.className = "pi pi-times comfy-sidebar-icon-btn";
sClear.title = t("Clear & Close Search");
Object.assign(sClear.style, { marginLeft: "6px" });
searchContainer.append(sIcon, sInput, sClear);
header.append(standardHeader, searchContainer);
const stickyHeaderGroup = document.createElement("div");
stickyHeaderGroup.className = "comfy-sidebar-sticky-header-group";
stickyHeaderGroupEl = stickyHeaderGroup;
stickyHeaderGroup.appendChild(header);
let scrollBeforeSearch = 0;
searchIcon.onclick = (e) => {
e.stopPropagation();
const scrollEl = getScrollContainer(cardStackEl);
scrollBeforeSearch = scrollEl ? scrollEl.scrollTop : 0;
standardHeader.style.display = "none";
searchContainer.style.display = "flex";
sInput.focus();
};
let searchDebounce = null;
let searchSeq = 0;
const closeSearch = () => {
const hadFilter = Boolean(currentSearchQuery || sInput.value.trim());
sInput.value = "";
currentSearchQuery = "";
sIcon.className = "pi pi-search";
searchContainer.style.display = "none";
standardHeader.style.display = "flex";
// If the user didn't type a search query, do NOT re-fetch or wipe cards.
// The scroll position stays right where you are.
if (hadFilter) {
fetchGalleryData(currentSubfolder, "").then(() => {
renderGallery();
const scrollEl = getScrollContainer(cardStackEl);
if (scrollEl && scrollBeforeSearch > 0) {
scrollEl.scrollTop = scrollBeforeSearch;
requestAnimationFrame(() => { scrollEl.scrollTop = scrollBeforeSearch; });
}
});
}
};
sClear.onclick = (e) => { e.stopPropagation(); closeSearch(); };
sInput.onkeydown = (e) => { if (e.key === "Escape") closeSearch(); };
sInput.oninput = () => {
currentSearchQuery = sInput.value.trim();
if (searchDebounce) clearTimeout(searchDebounce);
sIcon.className = "pi pi-spin pi-spinner";
const seq = ++searchSeq;
searchDebounce = setTimeout(async () => {
let applied = false;
try {
applied = (await fetchGalleryData(currentSubfolder, currentSearchQuery)) !== false;
if (applied) renderGallery();
} finally {
// Only the latest search may stop the spinner
if (seq === searchSeq) sIcon.className = "pi pi-search";
}
}, 350);
};
const breadcrumbBar = document.createElement("div");
breadcrumbBar.className = "comfy-sidebar-breadcrumb-bar";
stickyHeaderGroup.appendChild(breadcrumbBar);
container.appendChild(stickyHeaderGroup);
const cardStack = document.createElement("div");
cardStackEl = cardStack;
Object.assign(cardStack.style, {
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");
scrollToTopBtnEl.onclick = (e) => {
e.stopPropagation();
const scrollEl = getScrollContainer(cardStackEl);
if (scrollEl) scrollEl.scrollTo({ top: 0, behavior: "smooth" });
};
container.appendChild(scrollToTopBtnEl);
window.addEventListener("keydown", handleCtrlDown);
window.addEventListener("keyup", handleCtrlUp);
window.addEventListener("blur", handleCtrlBlur);
// Reveal Spotlight: track cursor position over Gallery card and folder borders
setupSpotlightTracker(container, ".comfy-sidebar-card, .comfy-sidebar-folder-card");
document.addEventListener("scroll", updateScrollTopVisibility, { capture: true, passive: true });
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();
}
});
galleryResizeObserver.observe(container);
fetchGalleryData("").then(() => renderGallery());
return container;
}
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;
}
}
+173 -147
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 } 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,16 +95,65 @@ 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() {
if (!scrollToTopBtnEl || !store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) return;
const scrollEl = getScrollContainer();
if (!scrollToTopBtnEl) return;
if (!store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) {
scrollToTopBtnEl.style.display = "none";
return;
}
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) {
const viewportEl = scrollEl.parentElement || store.ui.sidebarContainer;
if (viewportEl && scrollToTopBtnEl.parentNode !== viewportEl) {
@@ -114,6 +168,8 @@ function updateScrollTopBtnVisibility() {
} else {
scrollToTopBtnEl.style.display = "none";
}
handleQueueHeaderScroll(scrollEl);
}
}
@@ -162,7 +218,8 @@ export function setupSidebarView() {
Object.assign(container.style, {
display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box",
background: "var(--comfy-menu-bg, #121212)", color: "var(--fg-color, #eee)", position: "relative"
background: "transparent",
color: "var(--fg-color, var(--p-text-color, #eee))", position: "relative"
});
const header = document.createElement("div");
@@ -213,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; }
};
@@ -257,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"
});
@@ -281,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");
@@ -305,7 +363,12 @@ export function setupSidebarView() {
header.appendChild(standardHeader);
header.appendChild(searchContainer);
container.appendChild(header);
const stickyHeaderGroup = document.createElement("div");
stickyHeaderGroup.className = "comfy-sidebar-sticky-header-group";
stickyHeaderGroupEl = stickyHeaderGroup;
stickyHeaderGroup.appendChild(header);
container.appendChild(stickyHeaderGroup);
searchIcon.addEventListener("click", (e) => {
e.stopPropagation();
@@ -336,7 +399,7 @@ export function setupSidebarView() {
overflowY: "visible",
display: "flex",
flexDirection: "column",
paddingBottom: "28px"
paddingBottom: "46px"
});
container.appendChild(cardStack);
@@ -346,17 +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
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();
@@ -395,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");
@@ -481,79 +547,60 @@ 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();
if (shouldDeleteFromDisk && img && img.filename) {
const action = await deleteFileOnServer(img);
if (action) {
const msg = action === "trashed" ? t("Moved file to Recycle Bin.") : t("Permanently deleted file.");
showNotification(msg, "success", t("Delete File"));
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);
if (action) {
const msg = action === "trashed" ? t("Moved file to Recycle Bin.") : t("Permanently deleted file.");
showNotification(msg, "success", t("Delete File"));
}
}
}
const pid = batchInfo.parentPromptId || batchInfo.pid;
const state = store.getPrompt(pid);
const imgIdx = batchInfo.images.indexOf(img);
if (imgIdx > -1) batchInfo.images.splice(imgIdx, 1);
const pid = batchInfo.parentPromptId || batchInfo.pid;
const state = store.getPrompt(pid);
const imgIdx = batchInfo.images.indexOf(img);
if (imgIdx > -1) batchInfo.images.splice(imgIdx, 1);
if (state) {
if (Array.isArray(state.images)) {
const stIdx = state.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
if (stIdx > -1) state.images.splice(stIdx, 1);
if (state) {
if (Array.isArray(state.images)) {
const stIdx = state.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
if (stIdx > -1) state.images.splice(stIdx, 1);
}
if (state.nodeOutputs) {
removeImageFromNodeOutputs(state.nodeOutputs, img);
}
state.rendered = false;
store.updatePrompt(pid, state);
}
if (state.nodeOutputs) {
removeImageFromNodeOutputs(state.nodeOutputs, img);
const mainCardObj = cardPool.get(pid);
if (mainCardObj) {
mainCardObj.currentImageIndex = 0;
mainCardObj.lastImagesSignature = "";
}
state.rendered = false;
store.updatePrompt(pid, state);
}
const mainCardObj = cardPool.get(pid);
if (mainCardObj) {
mainCardObj.currentImageIndex = 0;
mainCardObj.lastImagesSignature = "";
}
if (batchInfo.images.length === 0) {
if (batchInfo.parentPromptId) {
store.openOutputsSubmenu(batchInfo.parentPromptId);
} else {
store.closeSubmenu();
if (batchInfo.images.length === 0) {
if (batchInfo.parentPromptId) {
store.openOutputsSubmenu(batchInfo.parentPromptId);
} else {
store.closeSubmenu();
}
}
}
renderSidebar();
renderSidebar();
});
}
};
});
hoverPanel.append(btnImg, btnDel);
@@ -634,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;
@@ -752,68 +799,49 @@ 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();
if (shouldDeleteFromDisk && out.images) {
let count = 0;
let lastAct = "trashed";
for (const imgItem of out.images) {
const action = await deleteFileOnServer(imgItem);
if (action) { count++; lastAct = action; }
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;
let lastAct = "trashed";
for (const imgItem of out.images) {
const action = await deleteFileOnServer(imgItem);
if (action) { count++; lastAct = action; }
}
if (count > 0) {
const msg = lastAct === "trashed"
? `Moved ${count} file${count > 1 ? "s" : ""} to Recycle Bin.`
: `Permanently deleted ${count} file${count > 1 ? "s" : ""}.`;
showNotification(msg, "success", "Delete Files");
}
}
if (count > 0) {
const msg = lastAct === "trashed"
? `Moved ${count} file${count > 1 ? "s" : ""} to Recycle Bin.`
: `Permanently deleted ${count} file${count > 1 ? "s" : ""}.`;
showNotification(msg, "success", "Delete Files");
if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) {
delete st.nodeOutputs[out.nodeId];
}
}
if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) {
delete st.nodeOutputs[out.nodeId];
}
const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
if (remainingOutputs.length > 0) {
st.images = remainingOutputs[remainingOutputs.length - 1].images || [];
} else {
st.images = [];
store.closeSubmenu();
}
const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
if (remainingOutputs.length > 0) {
st.images = remainingOutputs[remainingOutputs.length - 1].images || [];
} else {
st.images = [];
store.closeSubmenu();
}
store.updatePrompt(st.pid, st);
renderSidebar();
store.updatePrompt(st.pid, st);
renderSidebar();
});
}
};
});
hoverPanel.append(btnImg, btnDel);
@@ -887,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;
}
@@ -956,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();
};
@@ -981,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();
};
@@ -1000,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);
@@ -1011,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; });
@@ -1038,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);
};
}
+130 -72
View File
@@ -56,6 +56,106 @@ export function showNotification(message, type = "success", title = null) {
}, 3500);
}
function remapWidgetValue(val, oldToNew) {
if (typeof val === "string") {
const match = val.match(/^\$([^-]+)-(.*)$/);
if (match) {
const mapped = oldToNew.get(match[1]) ?? oldToNew.get(Number(match[1]));
if (mapped != null) {
return `$${mapped}-${match[2]}`;
}
}
}
return val;
}
function applyIdMappingToGraph(oldToNew) {
if (!app.graph) return;
const graphData = app.graph.serialize();
if (!graphData) return;
// 1. Remap node IDs and internal widget references
if (Array.isArray(graphData.nodes)) {
for (const node of graphData.nodes) {
const oldId = node.id;
const newId = oldToNew.get(oldId) ?? oldToNew.get(String(oldId)) ?? oldToNew.get(Number(oldId));
if (newId != null) {
node.id = newId;
}
if (Array.isArray(node.widgets_values)) {
node.widgets_values = node.widgets_values.map(val => remapWidgetValue(val, oldToNew));
} else if (node.widgets_values && typeof node.widgets_values === "object") {
for (const key of Object.keys(node.widgets_values)) {
node.widgets_values[key] = remapWidgetValue(node.widgets_values[key], oldToNew);
}
}
}
}
// 2. Remap link endpoints (both array tuples and object representations)
const linkList = Array.isArray(graphData.links)
? graphData.links
: (graphData.links && typeof graphData.links === "object" ? Object.values(graphData.links) : []);
for (const link of linkList) {
if (!link) continue;
if (Array.isArray(link)) {
// [id, origin_id, origin_slot, target_id, target_slot, type]
const originId = link[1];
const targetId = link[3];
const newOrigin = oldToNew.get(originId) ?? oldToNew.get(String(originId)) ?? oldToNew.get(Number(originId));
if (newOrigin != null) link[1] = newOrigin;
const newTarget = oldToNew.get(targetId) ?? oldToNew.get(String(targetId)) ?? oldToNew.get(Number(targetId));
if (newTarget != null) link[3] = newTarget;
} else if (typeof link === "object") {
const originId = link.origin_id;
const targetId = link.target_id;
const newOrigin = oldToNew.get(originId) ?? oldToNew.get(String(originId)) ?? oldToNew.get(Number(originId));
if (newOrigin != null) link.origin_id = newOrigin;
const newTarget = oldToNew.get(targetId) ?? oldToNew.get(String(targetId)) ?? oldToNew.get(Number(targetId));
if (newTarget != null) link.target_id = newTarget;
}
}
// 3. Remap group membership if defined
if (Array.isArray(graphData.groups)) {
for (const group of graphData.groups) {
if (Array.isArray(group.nodes)) {
group.nodes = group.nodes.map(id => oldToNew.get(id) ?? oldToNew.get(String(id)) ?? oldToNew.get(Number(id)) ?? id);
}
}
}
// 4. Update graph last_node_id counter
let maxId = 0;
for (const val of oldToNew.values()) {
const num = Number(val);
if (!isNaN(num) && num > maxId) maxId = num;
}
graphData.last_node_id = Math.max(graphData.last_node_id || 0, maxId, graphData.nodes?.length || 0);
// 5. Preserve canvas pan/zoom coordinates so the screen doesn't jump
const currentScale = app.canvas?.ds?.scale;
const currentOffset = app.canvas?.ds?.offset ? [...app.canvas.ds.offset] : null;
app.graph.configure(graphData);
if (app.canvas?.ds && currentScale != null && currentOffset != null) {
app.canvas.ds.scale = currentScale;
app.canvas.ds.offset[0] = currentOffset[0];
app.canvas.ds.offset[1] = currentOffset[1];
}
if (typeof app.graph.afterChange === "function") {
app.graph.afterChange();
}
app.graph.setDirtyCanvas(true, true);
if (app.canvas?.draw) {
app.canvas.draw(true, true);
}
syncAllNodeBadges();
}
export async function renumberNodesTopologically() {
if (!app.graph || !app.graph._nodes || app.graph._nodes.length === 0) {
showNotification(t("No nodes found on canvas to renumber."), "warn");
@@ -88,55 +188,21 @@ export async function renumberNodesTopologically() {
return posYA - posYB;
});
const isStringId = typeof sortedNodes[0]?.id === "string";
const oldToNew = new Map();
sortedNodes.forEach((node, index) => {
const newId = index + 1;
const newId = isStringId ? String(index + 1) : (index + 1);
oldToNew.set(node.id, newId);
oldToNew.set(String(node.id), newId);
oldToNew.set(Number(node.id), newId);
});
// 1. Remap node IDs in-place on app.graph
app.graph._nodes_by_id = {};
sortedNodes.forEach(node => {
const oldId = node.id;
const newId = oldToNew.get(oldId);
if (newId != null) {
// Update internal widget IDs like "$2034-0" (PreviewBridge)
if (Array.isArray(node.widgets)) {
for (const w of node.widgets) {
if (typeof w.value === "string" && w.value.startsWith(`$${oldId}-`)) {
w.value = w.value.replace(`$${oldId}-`, `$${newId}-`);
}
}
}
node.id = newId;
app.graph._nodes_by_id[newId] = node;
}
});
// 2. Remap links
if (app.graph.links) {
const links = app.graph.links instanceof Map
? Array.from(app.graph.links.values())
: Object.values(app.graph.links);
links.forEach(link => {
if (!link) return;
if (oldToNew.has(link.origin_id)) link.origin_id = oldToNew.get(link.origin_id);
if (oldToNew.has(link.target_id)) link.target_id = oldToNew.get(link.target_id);
});
}
app.graph.last_node_id = sortedNodes.length;
app.graph.setDirtyCanvas(true, true);
if (app.canvas?.draw) {
app.canvas.draw(true, true);
}
syncAllNodeBadges();
applyIdMappingToGraph(oldToNew);
const lastNode = sortedNodes[sortedNodes.length - 1];
const lastNodeName = lastNode ? (lastNode.title || lastNode.type) : "SaveImage";
showNotification(t("Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).", { count: sortedNodes.length, id: lastNode ? lastNode.id : "", name: lastNodeName }), "success");
const lastNewId = lastNode ? oldToNew.get(lastNode.id) : "";
showNotification(t("Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).", { count: sortedNodes.length, id: lastNewId, name: lastNodeName }), "success");
} catch (err) {
console.error("ComfySidebar: Failed to renumber nodes", err);
showNotification(t("Failed to renumber nodes: ") + err.message, "error");
@@ -159,55 +225,47 @@ export async function setManualNodeId(targetNode = null) {
const input = prompt(t("Enter new ID for node #{id} ({name}):", { id: currentId, name: node.title || node.type }), String(currentId));
if (!input) return;
const newId = parseInt(input.trim(), 10);
if (isNaN(newId) || newId <= 0) {
const newIdNum = parseInt(input.trim(), 10);
if (isNaN(newIdNum) || newIdNum <= 0) {
showNotification(t("Please enter a valid positive number."), "error");
return;
}
if (newId === currentId) return;
if (String(newIdNum) === String(currentId)) return;
try {
const existingNode = app.graph.getNodeById ? app.graph.getNodeById(newId) : null;
const existingNode = app.graph.getNodeById ? (app.graph.getNodeById(newIdNum) || app.graph.getNodeById(String(newIdNum))) : null;
if (existingNode && existingNode !== node) {
const confirmSwap = confirm(t("Node ID #{id} is already used by \"{name}\". Swap their IDs?", { id: newId, name: existingNode.title || existingNode.type }));
const confirmSwap = confirm(t("Node ID #{id} is already used by \"{name}\". Swap their IDs?", { id: newIdNum, name: existingNode.title || existingNode.type }));
if (!confirmSwap) return;
}
const isStringId = typeof currentId === "string";
const newId = isStringId ? String(newIdNum) : newIdNum;
const oldId = currentId;
const oldToNew = new Map();
if (existingNode && existingNode !== node) {
oldToNew.set(currentId, newId);
oldToNew.set(newId, currentId);
existingNode.id = currentId;
const existingId = existingNode.id;
oldToNew.set(oldId, newId);
oldToNew.set(String(oldId), newId);
oldToNew.set(Number(oldId), newId);
oldToNew.set(existingId, oldId);
oldToNew.set(String(existingId), oldId);
oldToNew.set(Number(existingId), oldId);
} else {
oldToNew.set(currentId, newId);
}
node.id = newId;
// Rebuild lookup map
app.graph._nodes_by_id = {};
for (const n of app.graph._nodes) {
app.graph._nodes_by_id[n.id] = n;
oldToNew.set(oldId, newId);
oldToNew.set(String(oldId), newId);
oldToNew.set(Number(oldId), newId);
}
// Remap links
if (app.graph.links) {
const links = app.graph.links instanceof Map
? Array.from(app.graph.links.values())
: Object.values(app.graph.links);
applyIdMappingToGraph(oldToNew);
links.forEach(link => {
if (!link) return;
if (oldToNew.has(link.origin_id)) link.origin_id = oldToNew.get(link.origin_id);
if (oldToNew.has(link.target_id)) link.target_id = oldToNew.get(link.target_id);
});
const updatedNode = app.graph.getNodeById ? (app.graph.getNodeById(newId) || app.graph.getNodeById(String(newId))) : null;
if (updatedNode && app.canvas?.selectNode) {
app.canvas.selectNode(updatedNode);
}
app.graph.last_node_id = Math.max(app.graph.last_node_id || 0, newId, app.graph._nodes.length);
app.graph.setDirtyCanvas(true, true);
if (app.canvas?.draw) {
app.canvas.draw(true, true);
}
syncAllNodeBadges();
showNotification(t("Assigned node ID #{id} to \"{name}\".", { id: newId, name: node.title || node.type }), "success");
} catch (err) {
console.error("ComfySidebar: Failed to set node ID", err);
+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)) {
+407 -46
View File
@@ -4,21 +4,55 @@ export function injectStyles() {
style.id = "comfy-sidebar-styles";
style.textContent = `
.comfy-sidebar-card {
background: var(--comfy-input-bg, #181818);
/* 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, #333);
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;
}
.comfy-sidebar-card:hover {
border-color: var(--p-primary-color, var(--primary-color, #555)) !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;
}
.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; }
/* Border & Frame Spotlight (Uses the theme's main hue) */
.comfy-sidebar-card::before,
.comfy-sidebar-folder-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(
160px circle at var(--mouse-x, -999px) var(--mouse-y, -999px),
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;
pointer-events: none;
z-index: 1;
}
.comfy-sidebar-card:hover::before,
.comfy-sidebar-folder-card:hover::before {
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; }
/* 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-card.pending .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active .comfy-sidebar-queue-cancel-btn {
@@ -44,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,
@@ -155,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;
@@ -189,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;
@@ -200,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 {
@@ -222,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 */
@@ -259,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 {
@@ -454,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;
@@ -514,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;
@@ -522,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 */
@@ -555,9 +696,11 @@ export function injectStyles() {
z-index: 1005 !important;
}
/* Explicit card element layering so badges and [X] are ALWAYS on top of images */
.comfy-sidebar-card .comfy-sidebar-media-wrapper {
z-index: 1 !important;
/* 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;
}
.comfy-sidebar-card-timer {
z-index: 10 !important;
@@ -569,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,
@@ -584,11 +741,215 @@ export function injectStyles() {
border-radius: 3px !important;
}
/* Gallery delete button only shows in the Ctrl alternative actions menu */
.comfy-sidebar-gallery-btn-del {
display: none !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-gallery-btn-del,
.comfy-sidebar-gallery-btn-del.confirm-delete,
.comfy-sidebar-gallery-btn-del.confirm-delete-disk {
display: inline-flex !important;
}
/* Sticky Header & Nav Reveal (Seamlessly transparent at rest, frosted on scroll) */
.comfy-sidebar-sticky-header-group {
position: sticky;
top: 0;
margin: -14px -14px 6px -14px;
padding: 14px 14px 0 14px;
background: transparent;
z-index: 35;
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), background 0.2s ease, box-shadow 0.2s ease;
will-change: transform;
}
.comfy-sidebar-sticky-header-group.is-scrolled {
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 */
.comfy-sidebar-breadcrumb-bar {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
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, 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: 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(--foreground, var(--fg-color, #eee));
text-decoration: none;
}
.comfy-sidebar-breadcrumb-item:hover {
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 {
opacity: 0.4;
font-size: 10px;
}
.comfy-sidebar-folders-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 8px;
margin-bottom: 12px;
width: 100%;
}
.comfy-sidebar-folder-card {
/* 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, 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-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, 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;
/* 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(--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;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.comfy-sidebar-card-filename-badge {
position: absolute;
top: 6px;
right: 8px;
font-size: 10px;
font-family: monospace;
opacity: 0.7;
background: rgba(0, 0, 0, 0.6);
padding: 2px 4px;
border-radius: 3px;
pointer-events: none;
z-index: 5;
color: #fff;
max-width: 62%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transform: translateZ(0);
}
/* Folder card layout & Ctrl overlay support */
.comfy-sidebar-folder-card {
position: relative;
overflow: hidden;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-folder-card:hover .comfy-sidebar-ctrl-overlay {
display: flex;
}
/* Comparison selected image */
.comfy-sidebar-card.comfy-sidebar-comparison-source {
outline: 2px solid #eab308 !important;
box-shadow: 0 0 12px rgba(234, 179, 8, 0.5);
}
/* Smooth animation for the circular loader */
@keyframes comfy-sidebar-spin {
from { transform: rotate(0deg); }
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;
+162 -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;
@@ -431,4 +484,102 @@ export async function extractWorkflowFromPng(imageUrl) {
console.warn("Comfy Sidebar: Could not extract workflow from PNG", e);
}
return null;
}
export function animateCardRemoval(cardEl, callback, duration = 200) {
if (!cardEl) {
if (callback) callback();
return;
}
cardEl.style.transition = `opacity ${duration}ms ease, transform ${duration}ms ease`;
cardEl.style.opacity = "0";
cardEl.style.transform = "scale(0.85)";
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.7.2"
version = "1.8.3"
license = { file = "LICENSE" }
dependencies = [
"send2trash>=1.8.0",