Compare commits

..
69 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
m0rtus59 717bb901e5 add locales 2026-10-17 09:53:10 +05:00
m0rtus59 426c7d5bd0 better compatibility with the recent frontend UI update 2026-10-14 22:56:51 +05:00
m0rtus59 4707921f81 improve ghost cards cleaning 2026-10-14 22:42:02 +05:00
m0rtus59 01b5b36522 improve 'comfy layout' toggle logic 2026-10-09 20:53:18 +05:00
m0rtus59 5068f155bc improved canvas toolbar docking logic 2026-10-09 20:10:20 +05:00
57 changed files with 5441 additions and 852 deletions
+4 -1
View File
@@ -1,2 +1,5 @@
__pycache__/
*.pyc
*.pyc
Thumbs.db
.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;
}
}
+214 -133
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,
.shadow-interface.rounded-lg.border-interface-stroke,
.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 matching dark background and seamless border */
/* Dock actionbar buttons in the topbar row with transparent background */
.actionbar-container,
[class*="actionbar"]:not(.actionbar),
[class*="actionbar-buttons"],
.actionbar-buttons {
@@ -42,9 +47,8 @@ div.shadow-interface.py-1\\.75 {
height: var(--topbar-height, 40px) !important;
transform: none !important;
z-index: 1010 !important;
background: var(--comfy-menu-bg, #181818) !important;
background: transparent !important;
border: none !important;
border-bottom: 1px solid var(--interface-stroke, var(--border-color, rgba(255, 255, 255, 0.12))) !important;
box-shadow: none !important;
border-radius: 0 !important;
padding: 0 8px !important;
@@ -85,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;
@@ -129,6 +147,7 @@ function findSidebarBottomTarget(sidebar) {
function stripBorderRadius(canvasMenu) {
if (!canvasMenu) return;
canvasMenu.style.setProperty("border-radius", "0px", "important");
const elements = canvasMenu.querySelectorAll("button, [role='button'], div, i, span");
elements.forEach((el) => {
el.style.setProperty("border-radius", "0px", "important");
@@ -144,6 +163,7 @@ function syncCanvasMenuPlacement(mode) {
hideStyleEl = document.createElement("style");
hideStyleEl.id = HIDE_CANVAS_MENU_STYLE_ID;
hideStyleEl.textContent = `
[role="toolbar"]:has([data-testid="zoom-controls-button"]):has([data-testid="toggle-minimap-button"]),
.p-buttongroup.z-1200,
.p-buttongroup.bottom-0.right-0,
[class*="bottom-0"][class*="right-0"][class*="p-buttongroup"],
@@ -160,7 +180,8 @@ function syncCanvasMenuPlacement(mode) {
if (canvasMenu && canvasMenu._originalParent) {
canvasMenu._originalParent.insertBefore(canvasMenu, canvasMenu._originalNextSibling || null);
canvasMenu.classList.remove("comfy-sidebar-unfloated-canvas-menu");
const elements = canvasMenu.querySelectorAll("button, [role='button'], div, i, span");
canvasMenu.style.removeProperty("border-radius");
const elements = canvasMenu.querySelectorAll("*");
elements.forEach((el) => {
el.style.removeProperty("border-radius");
});
@@ -176,11 +197,22 @@ function syncCanvasMenuPlacement(mode) {
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar) return;
// Stamp sidebar position and width for instant CSS positioning of modals
document.documentElement.dataset.sidebarPosition = isSidebarOnLeft() ? "left" : "right";
const sidebarWidth = Math.round(sidebar.getBoundingClientRect().width) || 60;
document.documentElement.style.setProperty('--comfy-sidebar-dock-width', `${sidebarWidth}px`);
const bottomTarget = findSidebarBottomTarget(sidebar);
const targetParent = bottomTarget ? bottomTarget.parentNode : sidebar;
// Find any floating toolbar on the canvas
const floatingMenu = document.querySelector(
// Find floating canvas toolbar by semantic role and test IDs, with class fallback
const floatingMenu = Array.from(
document.querySelectorAll('[role="toolbar"]')
).find((el) =>
!el.classList.contains("comfy-sidebar-unfloated-canvas-menu") &&
el.querySelector('[data-testid="zoom-controls-button"]') &&
el.querySelector('[data-testid="toggle-minimap-button"]')
) || document.querySelector(
'.p-buttongroup.z-1200:not(.comfy-sidebar-unfloated-canvas-menu), ' +
'.p-buttongroup.bottom-0.right-0:not(.comfy-sidebar-unfloated-canvas-menu), ' +
'[class*="bottom-0"][class*="right-0"][class*="p-buttongroup"]:not(.comfy-sidebar-unfloated-canvas-menu), ' +
@@ -215,25 +247,35 @@ function syncCanvasMenuPlacement(mode) {
if (!canvasMenu._clickGuardAttached) {
canvasMenu._clickGuardAttached = true;
const enforce = () => {
if (!canvasMenu.classList.contains("comfy-sidebar-unfloated-canvas-menu")) return;
requestAnimationFrame(() => stripBorderRadius(canvasMenu));
};
canvasMenu.addEventListener("click", enforce);
canvasMenu.addEventListener("pointerup", enforce);
canvasMenu.addEventListener("mouseenter", enforce, true);
}
} else {
stripBorderRadius(canvasMenu);
}
} else {
const canvasMenu = document.querySelector('.comfy-sidebar-unfloated-canvas-menu');
if (canvasMenu && canvasMenu._originalParent) {
canvasMenu._originalParent.insertBefore(canvasMenu, canvasMenu._originalNextSibling || null);
// Return to floating mode: completely restore stock parent and enforce 8px rounded corners
const canvasMenu = document.querySelector('.comfy-sidebar-unfloated-canvas-menu') ||
document.querySelector('[role="toolbar"]') ||
document.querySelector('.floating-panel');
if (canvasMenu && canvasMenu.classList.contains("comfy-sidebar-unfloated-canvas-menu")) {
if (canvasMenu._originalParent) {
canvasMenu._originalParent.insertBefore(canvasMenu, canvasMenu._originalNextSibling || null);
}
canvasMenu.classList.remove("comfy-sidebar-unfloated-canvas-menu");
const elements = canvasMenu.querySelectorAll("button, [role='button'], div, i, span");
elements.forEach((el) => {
}
if (canvasMenu) {
canvasMenu.style.setProperty("border-radius", "8px", "important");
canvasMenu.querySelectorAll("*").forEach((el) => {
el.style.removeProperty("border-radius");
});
}
delete document.documentElement.dataset.sidebarPosition;
document.documentElement.style.removeProperty('--comfy-sidebar-dock-width');
}
}
@@ -276,11 +318,42 @@ function setupCanvasMenuTooltipRelocator() {
}, true);
}
// Popovers / Menus: positions popup on the screen side of the sidebar
// Popovers / Menus: positions popups and modals next to the docked sidebar button
function setupCanvasMenuPopoverRelocator() {
if (window._canvasPopoverRelocatorReady) return;
window._canvasPopoverRelocatorReady = true;
// Instantly pre-calculate coordinates on pointerdown so the Zoom modal renders in place with ZERO flash
const preAnchorZoomModal = (e) => {
const btn = e.target?.closest?.(
'.comfy-sidebar-unfloated-canvas-menu [data-testid="zoom-controls-button"], ' +
'.comfy-sidebar-unfloated-canvas-menu button[title*="Zoom"], ' +
'.comfy-sidebar-unfloated-canvas-menu button[aria-label*="Zoom"], ' +
'.comfy-sidebar-unfloated-canvas-menu [class*="w-15"]'
);
if (!btn) return;
const btnRect = btn.getBoundingClientRect();
const onLeft = btnRect.left < window.innerWidth / 2;
const modalWidth = 250;
const modalHeight = 165;
// Anchor 8px away from the button's edge
const leftPos = onLeft
? Math.round(btnRect.right + 8)
: Math.round(btnRect.left - modalWidth - 8);
// Center vertically with the button, clamped to viewport bounds
const maxTop = window.innerHeight - modalHeight - 12;
const topPos = Math.max(12, Math.min(maxTop, Math.round(btnRect.top - (modalHeight / 2) + (btnRect.height / 2))));
document.documentElement.style.setProperty('--zoom-modal-left', `${leftPos}px`);
document.documentElement.style.setProperty('--zoom-modal-top', `${topPos}px`);
};
document.addEventListener("pointerdown", preAnchorZoomModal, true);
document.addEventListener("click", preAnchorZoomModal, true);
document.addEventListener("click", (e) => {
const btn = e.target?.closest?.(".comfy-sidebar-unfloated-canvas-menu button, .comfy-sidebar-unfloated-canvas-menu [role='button']");
if (!btn) return;
@@ -290,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")) {
@@ -317,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;
@@ -349,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");
@@ -373,19 +441,11 @@ export function applyClassicLayout(enable, updateSetting = false) {
window.removeEventListener("resize", updateTopMetrics);
document.documentElement.style.removeProperty('--actionbar-width');
document.documentElement.style.removeProperty('--topbar-height');
if (updateSetting) {
if (app.extensionManager?.setting) {
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
} else if (app.ui?.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
}
}
}
}
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);
@@ -433,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;
@@ -445,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;
@@ -484,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) {
@@ -532,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,
@@ -545,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");
@@ -563,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());
}
}
});
@@ -575,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) {
@@ -623,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();
@@ -671,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>`;
@@ -678,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).";
}
@@ -699,10 +728,19 @@ export function syncClassicLayout() {
if (domObserver) domObserver.disconnect();
try {
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
let canvasControlsMode = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Floating Canvas Controls");
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);
setSetting("Comfy Sidebar.Comfy Layout", false);
return;
}
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";
@@ -719,6 +757,10 @@ export function syncClassicLayout() {
syncPropertiesButton(isClassicLayoutEnabled);
syncActiveQueueIndicator(isClassicLayoutEnabled);
updateSidebarTabsVisibility();
applySidebarOverride();
if (!sidebarTopObserver) {
setupSidebarTopObserver();
}
syncRuntimePreviewButton();
} catch (err) {
@@ -736,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");
@@ -752,20 +795,38 @@ 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;
}
/* Ensure floating canvas toolbar keeps its native rounded corners when undocked */
.floating-panel:not(.comfy-sidebar-unfloated-canvas-menu),
[role="toolbar"]:not(.comfy-sidebar-unfloated-canvas-menu),
.p-buttongroup:not(.comfy-sidebar-unfloated-canvas-menu) {
border-radius: 8px !important;
}
/* When docked, instantly anchor Zoom modal right beside the Zoom button with zero flash */
html:has(.comfy-sidebar-unfloated-canvas-menu) div.z-1300,
html:has(.comfy-sidebar-unfloated-canvas-menu) [class*="bottom-[62px]"],
html:has(.comfy-sidebar-unfloated-canvas-menu) [class*="bottom-\\[62px\\]"] {
position: fixed !important;
left: var(--zoom-modal-left, -9999px) !important;
top: var(--zoom-modal-top, -9999px) !important;
right: auto !important;
bottom: auto !important;
}
/* Tooltip positioning: suppress top tooltip flash and show cleanly on left/right */
body:has(.comfy-sidebar-unfloated-canvas-menu :hover) .p-tooltip-top {
visibility: hidden !important;
@@ -853,21 +914,33 @@ export function setupPropertiesPanelToggleFix() {
border-radius: 0 !important;
}
/* Neutralize CanvasModeSelector's inner selected background and padding */
/* 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"] {
border-radius: 0 !important;
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 */
/* Compact CanvasModeSelector padding and chevrons to eliminate extra sidebar width and ensure full-width highlight */
.comfy-sidebar-unfloated-canvas-menu [class*="pr-0.5"],
.comfy-sidebar-unfloated-canvas-menu div.flex.items-center {
.comfy-sidebar-unfloated-canvas-menu div.flex.items-center,
.comfy-sidebar-unfloated-canvas-menu > div,
.comfy-sidebar-unfloated-canvas-menu > div > button {
padding: 0 !important;
margin: 0 !important;
gap: 2px !important;
justify-content: center !important;
width: 100% !important;
border-radius: 0 !important;
}
/* Compact the Zoom button: strip w-15 (60px) and px-2 padding */
@@ -991,6 +1064,10 @@ export function setupPropertiesPanelToggleFix() {
}
export function destroyLayoutFix() {
delete document.documentElement.dataset.sidebarPosition;
document.documentElement.style.removeProperty('--comfy-sidebar-dock-width');
document.documentElement.style.removeProperty('--zoom-modal-left');
document.documentElement.style.removeProperty('--zoom-modal-top');
syncCanvasMenuPlacement("Default");
const hideCanvasStyle = document.getElementById("comfy-sidebar-hide-canvas-menu-override");
if (hideCanvasStyle) hideCanvasStyle.remove();
@@ -998,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",
+70
View File
@@ -0,0 +1,70 @@
import { app } from "/scripts/app.js";
let currentLocale = "en";
let translations = {};
let fallbackTranslations = {};
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 {
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");
if (!settingVal) {
try {
settingVal = localStorage.getItem("Comfy.Settings.Comfy.Locale")
|| localStorage.getItem("Comfy.Locale");
} catch (e) {}
}
const raw = (settingVal || "en").toLowerCase().replace("_", "-");
const candidates = [raw];
const base = raw.split("-")[0];
if (base !== raw) candidates.push(base);
currentLocale = candidates[0];
console.log("[ComfySidebar] Detected locale:", currentLocale);
// 1. Load English fallback
fallbackTranslations = await fetchLocaleFiles("en");
// 2. Load active locale if not English
if (base !== "en") {
for (const cand of candidates) {
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) {
console.warn("[ComfySidebar] i18n initialization failed", e);
}
}
export function t(key, vars = {}) {
if (!key) return "";
let str = translations[key] || fallbackTranslations[key] || key;
for (const [k, v] of Object.entries(vars)) {
str = str.replaceAll(`{${k}}`, v);
}
return str;
}
+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);
}
+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.": "فشل تحميل محرك 3D.",
"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": "النواظم (Normals)",
"Perspective": "منظور",
"Orthographic": "إسقاط متوازي",
"Grid": "الشبكة",
"Auto-Rotate": "تدوير تلقائي",
"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 عند تفعيل تخطيط آخر. ولا تتأثر الكتابة في حقول النص."
}
+118
View File
@@ -0,0 +1,118 @@
{
"Queue": "Queue",
"Search History": "Search History",
"Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)": "Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)",
"No nodes selected. Click a node on the canvas first to toggle ignore.": "No nodes selected. Click a node on the canvas first to toggle ignore.",
"Renumber All Nodes by Execution Order (Alt+R)": "Renumber All Nodes by Execution Order (Alt+R)",
"Clear Cancelled & Failed": "Clear Cancelled & Failed",
"Clear All History": "Clear All History",
"Filter by text, images, nodes...": "Filter by text, images, nodes...",
"Clear & Close Search": "Clear & Close Search",
"Scroll to Top": "Scroll to Top",
"Batch of #": "Batch of #",
"Go Back": "Go Back",
"Go Back to Queue": "Go Back to Queue",
"Outputs of #": "Outputs of #",
"No separate intermediate outputs found.": "No separate intermediate outputs found.",
"Cancel All Pending": "Cancel All Pending",
"Pending Queue:": "Pending Queue:",
"tasks": "tasks",
"Download Object": "Download Object",
"Copy to Clipboard": "Copy to Clipboard",
"Download JSON": "Download JSON",
"Delete Card (Hold Ctrl to delete file from disk)": "Delete Card (Hold Ctrl to delete file from disk)",
"Delete Card": "Delete Card",
"Delete File": "Delete File",
"Delete Files": "Delete Files",
"Show Node": "Show Node",
"Show Stopped/Failed Node": "Show Stopped/Failed Node",
"View all intermediate outputs": "View all intermediate outputs",
"Delete Card (Ctrl+Click to delete from disk)": "Delete Card (Ctrl+Click to delete from disk)",
"Ctrl+Click again to delete file from DISK / TRASH": "Ctrl+Click again to delete file from DISK / TRASH",
"Click again to confirm removing card": "Click again to confirm removing card",
"Remove from batch (Hold Ctrl to delete file from disk)": "Remove from batch (Hold Ctrl to delete file from disk)",
"Click again to confirm removing from batch": "Click again to confirm removing from batch",
"Delete node output (Hold Ctrl to delete file from disk)": "Delete node output (Hold Ctrl to delete file from disk)",
"Ctrl+Click again to delete this node's files from DISK / TRASH": "Ctrl+Click again to delete this node's files from DISK / TRASH",
"Click again to confirm removing output": "Click again to confirm removing output",
"Moved file to Recycle Bin.": "Moved file to Recycle Bin.",
"Permanently deleted file.": "Permanently deleted file.",
"Cancelled": "Cancelled",
"Error": "Error",
"Warning": "Warning",
"Cancel Queued Task": "Cancel Queued Task",
"Interrupt Execution": "Interrupt Execution",
"Click to read full text": "Click to read full text",
"No Outputs": "No Outputs",
"Processing...": "Processing...",
"Finishing...": "Finishing...",
"No workflow metadata found in this file.": "No workflow metadata found in this file.",
"Open Folder": "Open Folder",
"Open Location": "Open Location",
"View all images in this batch": "View all images in this batch",
"No nodes found on canvas to renumber.": "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?": "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?",
"Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).": "Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).",
"Failed to renumber nodes: ": "Failed to renumber nodes: ",
"Please select a node first to change its ID.": "Please select a node first to change its ID.",
"Enter new ID for node #{id} ({name}):": "Enter new ID for node #{id} ({name}):",
"Please enter a valid positive number.": "Please enter a valid positive number.",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "Node ID #{id} is already used by \"{name}\". Swap their IDs?",
"Assigned node ID #{id} to \"{name}\".": "Assigned node ID #{id} to \"{name}\".",
"Failed to set node ID: ": "Failed to set node ID: ",
"Video playback. Press Esc to close.": "Video playback. Press Esc to close.",
"Click image to zoom (100%/Fit) | Shift+Click another card to compare.": "Click image to zoom (100%/Fit) | Shift+Click another card to compare.",
"Toggle Loop": "Toggle Loop",
"Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.": "Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.",
"Click image to zoom | Drag image to compare slider | Esc to close.": "Click image to zoom | Drag image to compare slider | Esc to close.",
"Copy": "Copy",
"Copied!": "Copied!",
"Text Output": "Text Output",
"Text Output #": "Text Output #",
"Raw Text": "Raw Text",
"{wc} words • {chars} chars": "{wc} words • {chars} chars",
"3D Model": "3D Model",
"Loading 3D Asset...": "Loading 3D Asset...",
"Exporting {format}...": "Exporting {format}...",
"Failed to load 3D engine.": "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.": "Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.",
"Background Color": "Background Color",
"Up Direction": "Up Direction",
"Material Mode": "Material Mode",
"Camera Type": "Camera Type",
"FOV": "FOV",
"Light Intensity": "Light Intensity",
"Original (Y-Up)": "Original (Y-Up)",
"Z-Up (+90°)": "Z-Up (+90°)",
"Inverted (-90°)": "Inverted (-90°)",
"Original / Textured": "Original / Textured",
"Clay / Studio Matte": "Clay / Studio Matte",
"Normals": "Normals",
"Perspective": "Perspective",
"Orthographic": "Orthographic",
"Grid": "Grid",
"Auto-Rotate": "Auto-Rotate",
"Wireframe": "Wireframe",
"Reset View": "Reset View",
"Settings": "Settings",
"Export As...": "Export As...",
"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."
}
+118
View File
@@ -0,0 +1,118 @@
{
"Queue": "Cola",
"Search History": "Buscar en el historial",
"Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)": "Ignorar/Incluir nodo(s) seleccionado(s) (Ctrl+Q)",
"No nodes selected. Click a node on the canvas first to toggle ignore.": "No hay nodos seleccionados. Haz clic primero en un nodo del lienzo.",
"Renumber All Nodes by Execution Order (Alt+R)": "Renumerar nodos por orden de ejecución (Alt+R)",
"Clear Cancelled & Failed": "Limpiar cancelados y fallidos",
"Clear All History": "Limpiar todo el historial",
"Filter by text, images, nodes...": "Filtrar por texto, imágenes, nodos...",
"Clear & Close Search": "Limpiar y cerrar búsqueda",
"Scroll to Top": "Volver arriba",
"Batch of #": "Lote de #",
"Go Back": "Atrás",
"Go Back to Queue": "Volver a la cola",
"Outputs of #": "Salidas de #",
"No separate intermediate outputs found.": "No se encontraron salidas intermedias separadas.",
"Cancel All Pending": "Cancelar todos los pendientes",
"Pending Queue:": "Cola pendiente:",
"tasks": "tareas",
"Download Object": "Descargar objeto",
"Copy to Clipboard": "Copiar al portapapeles",
"Download JSON": "Descargar JSON",
"Delete Card (Hold Ctrl to delete file from disk)": "Eliminar tarjeta (Ctrl para borrar del disco)",
"Delete Card": "Eliminar tarjeta",
"Delete File": "Eliminar archivo",
"Delete Files": "Eliminar archivos",
"Show Node": "Mostrar nodo",
"Show Stopped/Failed Node": "Mostrar nodo detenido o fallido",
"View all intermediate outputs": "Ver todas las salidas intermedias",
"Delete Card (Ctrl+Click to delete from disk)": "Eliminar tarjeta (Ctrl+Clic para borrar del disco)",
"Ctrl+Click again to delete file from DISK / TRASH": "Ctrl+Clic de nuevo para borrar del DISCO",
"Click again to confirm removing card": "Haz clic de nuevo para confirmar la eliminación",
"Remove from batch (Hold Ctrl to delete file from disk)": "Quitar del lote (Ctrl para borrar del disco)",
"Click again to confirm removing from batch": "Haz clic de nuevo para quitar del lote",
"Delete node output (Hold Ctrl to delete file from disk)": "Eliminar salida (Ctrl para borrar del disco)",
"Ctrl+Click again to delete this node's files from DISK / TRASH": "Ctrl+Clic de nuevo para borrar del DISCO",
"Click again to confirm removing output": "Haz clic de nuevo para eliminar la salida",
"Moved file to Recycle Bin.": "Archivo movido a la papelera.",
"Permanently deleted file.": "Archivo eliminado permanentemente.",
"Cancelled": "Cancelado",
"Error": "Error",
"Warning": "Advertencia",
"Cancel Queued Task": "Cancelar tarea en cola",
"Interrupt Execution": "Interrumpir ejecución",
"Click to read full text": "Haz clic para leer el texto completo",
"No Outputs": "Sin salidas",
"Processing...": "Procesando...",
"Finishing...": "Finalizando...",
"No workflow metadata found in this file.": "No se encontraron metadatos de flujo en este archivo.",
"Open Folder": "Abrir carpeta",
"Open Location": "Abrir ubicación",
"View all images in this batch": "Ver todas las imágenes de este lote",
"No nodes found on canvas to renumber.": "No se encontraron nodos para renumerar en el lienzo.",
"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?": "Renumerar reescribirá los ID de todos los nodos según el orden de ejecución.\n\nEsto arregla salidas intermedias mostradas como resultado final, pero puede afectar a scripts externos.\n\n¿Deseas continuar?",
"Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).": "{count} nodos renumerados por orden de ejecución. Último nodo: #{id} ({name}).",
"Failed to renumber nodes: ": "Error al renumerar nodos: ",
"Please select a node first to change its ID.": "Selecciona primero un nodo para cambiar su ID.",
"Enter new ID for node #{id} ({name}):": "Introduce nuevo ID para el nodo #{id} ({name}):",
"Please enter a valid positive number.": "Introduce un número entero positivo válido.",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "El ID #{id} ya está en uso por \"{name}\". ¿Intercambiar sus ID?",
"Assigned node ID #{id} to \"{name}\".": "Asignado ID #{id} al nodo \"{name}\".",
"Failed to set node ID: ": "Error al asignar ID de nodo: ",
"Video playback. Press Esc to close.": "Reproducción de video. Pulsa Esc para cerrar.",
"Click image to zoom (100%/Fit) | Shift+Click another card to compare.": "Clic para zoom (100%/Ajustar) | Shift+Clic en otra tarjeta para comparar.",
"Toggle Loop": "Bucle continuo",
"Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.": "Proporciones incompatibles ({wA}x{hA} vs {wB}x{hB}). Las imágenes deben tener la misma relación de aspecto para compararse.",
"Click image to zoom | Drag image to compare slider | Esc to close.": "Clic para zoom | Arrastra para comparar | Esc para cerrar.",
"Copy": "Copiar",
"Copied!": "¡Copiado!",
"Text Output": "Salida de texto",
"Text Output #": "Salida de texto #",
"Raw Text": "Texto sin formato",
"{wc} words • {chars} chars": "{wc} palabras • {chars} car.",
"3D Model": "Modelo 3D",
"Loading 3D Asset...": "Cargando modelo 3D...",
"Exporting {format}...": "Exportando a {format}...",
"Failed to load 3D engine.": "Error al cargar el motor 3D.",
"Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.": "No se pudieron descargar las bibliotecas 3D. Comprueba tu conexión a Internet.",
"Background Color": "Color de fondo",
"Up Direction": "Orientación vertical",
"Material Mode": "Modo de material",
"Camera Type": "Tipo de cámara",
"FOV": "Campo de visión (FOV)",
"Light Intensity": "Intensidad de luz",
"Original (Y-Up)": "Original (Y arriba)",
"Z-Up (+90°)": "Z arriba (+90°)",
"Inverted (-90°)": "Invertido (-90°)",
"Original / Textured": "Original / Textura",
"Clay / Studio Matte": "Arcilla / Mate",
"Normals": "Normales",
"Perspective": "Perspectiva",
"Orthographic": "Ortográfica",
"Grid": "Cuadrícula",
"Auto-Rotate": "Rotación automática",
"Wireframe": "Malla / Alambre",
"Reset View": "Restablecer vista",
"Settings": "Ajustes",
"Export As...": "Exportar como...",
"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 هنگام فعال بودن چیدمان دیگر نیز کار کنند. تایپ در فیلدهای متنی تحت تأثیر قرار نمی‌گیرد."
}
+118
View File
@@ -0,0 +1,118 @@
{
"Queue": "File d'attente",
"Search History": "Rechercher dans l'historique",
"Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)": "Ignorer/Activer les nœuds sélectionnés (Ctrl+Q)",
"No nodes selected. Click a node on the canvas first to toggle ignore.": "Aucun nœud sélectionné. Cliquez d'abord sur un nœud du canevas.",
"Renumber All Nodes by Execution Order (Alt+R)": "Renuméroter tous les nœuds selon l'ordre d'exécution (Alt+R)",
"Clear Cancelled & Failed": "Effacer annulés et échoués",
"Clear All History": "Effacer tout l'historique",
"Filter by text, images, nodes...": "Filtrer par texte, image, nœud...",
"Clear & Close Search": "Effacer et fermer la recherche",
"Scroll to Top": "Haut de page",
"Batch of #": "Lot de #",
"Go Back": "Retour",
"Go Back to Queue": "Retour à la file",
"Outputs of #": "Sorties de #",
"No separate intermediate outputs found.": "Aucune sortie intermédiaire distincte trouvée.",
"Cancel All Pending": "Tout annuler en attente",
"Pending Queue:": "File d'attente:",
"tasks": "tâches",
"Download Object": "Télécharger l'objet",
"Copy to Clipboard": "Copier dans le presse-papier",
"Download JSON": "Télécharger le JSON",
"Delete Card (Hold Ctrl to delete file from disk)": "Supprimer la carte (Ctrl pour supprimer du disque)",
"Delete Card": "Supprimer la carte",
"Delete File": "Supprimer le fichier",
"Delete Files": "Supprimer les fichiers",
"Show Node": "Afficher le nœud",
"Show Stopped/Failed Node": "Afficher le nœud arrêté/échoué",
"View all intermediate outputs": "Voir toutes les sorties intermédiaires",
"Delete Card (Ctrl+Click to delete from disk)": "Supprimer la carte (Ctrl+Clic pour supprimer du disque)",
"Ctrl+Click again to delete file from DISK / TRASH": "Ctrl+Clic à nouveau pour supprimer du DISQUE",
"Click again to confirm removing card": "Cliquez à nouveau pour confirmer la suppression",
"Remove from batch (Hold Ctrl to delete file from disk)": "Retirer du lot (Ctrl pour supprimer du disque)",
"Click again to confirm removing from batch": "Cliquez à nouveau pour confirmer le retrait",
"Delete node output (Hold Ctrl to delete file from disk)": "Supprimer la sortie (Ctrl pour supprimer du disque)",
"Ctrl+Click again to delete this node's files from DISK / TRASH": "Ctrl+Clic à nouveau pour supprimer les fichiers du DISQUE",
"Click again to confirm removing output": "Cliquez à nouveau pour supprimer la sortie",
"Moved file to Recycle Bin.": "Fichier déplacé dans la corbeille.",
"Permanently deleted file.": "Fichier définitivement supprimé.",
"Cancelled": "Annulé",
"Error": "Erreur",
"Warning": "Avertissement",
"Cancel Queued Task": "Annuler la tâche",
"Interrupt Execution": "Interrompre l'exécution",
"Click to read full text": "Cliquer pour lire le texte complet",
"No Outputs": "Aucune sortie",
"Processing...": "Traitement...",
"Finishing...": "Finalisation...",
"No workflow metadata found in this file.": "Aucune métadonnée de workflow trouvée dans ce fichier.",
"Open Folder": "Ouvrir le dossier",
"Open Location": "Emplacement du fichier",
"View all images in this batch": "Voir toutes les images de ce lot",
"No nodes found on canvas to renumber.": "Aucun nœud à renuméroter sur le canevas.",
"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?": "La renumérotation réécrira tous les identifiants de nœuds selon l'ordre d'exécution.\n\nCela corrige l'affichage des sorties intermédiaires, mais peut affecter les scripts externes dépendant d'identifiants précis.\n\nVoulez-vous continuer ?",
"Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).": "{count} nœuds renumérotés par ordre d'exécution. Dernier nœud : #{id} ({name}).",
"Failed to renumber nodes: ": "Échec de la renumérotation : ",
"Please select a node first to change its ID.": "Veuillez d'abord sélectionner un nœud pour modifier son ID.",
"Enter new ID for node #{id} ({name}):": "Entrez le nouvel ID pour le nœud #{id} ({name}) :",
"Please enter a valid positive number.": "Veuillez entrer un entier positif valide.",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "L'ID #{id} est déjà utilisé par \"{name}\". Échanger leurs ID ?",
"Assigned node ID #{id} to \"{name}\".": "ID #{id} assigné au nœud \"{name}\".",
"Failed to set node ID: ": "Échec de l'assignation de l'ID : ",
"Video playback. Press Esc to close.": "Lecture vidéo. Appuyez sur Échap pour fermer.",
"Click image to zoom (100%/Fit) | Shift+Click another card to compare.": "Cliquer pour zoomer (100%/Ajuster) | Shift+Clic sur une autre carte pour comparer.",
"Toggle Loop": "Lecture en boucle",
"Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.": "Proportions incompatibles ({wA}x{hA} vs {wB}x{hB}). Les images doivent avoir le même ratio pour être comparées.",
"Click image to zoom | Drag image to compare slider | Esc to close.": "Cliquer pour zoomer | Glisser le curseur pour comparer | Échap pour fermer.",
"Copy": "Copier",
"Copied!": "Copié !",
"Text Output": "Sortie texte",
"Text Output #": "Sortie texte #",
"Raw Text": "Texte brut",
"{wc} words • {chars} chars": "{wc} mots • {chars} car.",
"3D Model": "Modèle 3D",
"Loading 3D Asset...": "Chargement de l'élément 3D...",
"Exporting {format}...": "Exportation au format {format}...",
"Failed to load 3D engine.": "Échec du chargement du moteur 3D.",
"Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.": "Impossible de télécharger les bibliothèques 3D. Vérifiez votre connexion Internet.",
"Background Color": "Couleur d'arrière-plan",
"Up Direction": "Orientation vers le haut",
"Material Mode": "Mode de rendu",
"Camera Type": "Type de caméra",
"FOV": "Champ de vision (FOV)",
"Light Intensity": "Intensité de la lumière",
"Original (Y-Up)": "Original (Y-Haut)",
"Z-Up (+90°)": "Z-Haut (+90°)",
"Inverted (-90°)": "Inversé (-90°)",
"Original / Textured": "Original / Texturé",
"Clay / Studio Matte": "Argile / Mat",
"Normals": "Normales",
"Perspective": "Perspective",
"Orthographic": "Orthographique",
"Grid": "Grille",
"Auto-Rotate": "Rotation auto",
"Wireframe": "Filaire",
"Reset View": "Réinitialiser la vue",
"Settings": "Paramètres",
"Export As...": "Exporter sous...",
"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."
}
+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.": "טעינת מנוע 3D נכשלה.",
"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 פועלים גם כשפריסה אחרת פעילה. ההקלדה בשדות טקסט אינה מושפעת."
}
+118
View File
@@ -0,0 +1,118 @@
{
"Queue": "Coda",
"Search History": "Cerca nella cronologia",
"Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)": "Ignora/Attiva nodo/i selezionato/i (Ctrl+Q)",
"No nodes selected. Click a node on the canvas first to toggle ignore.": "Nessun nodo selezionato. Clicca prima su un nodo nell'area di lavoro.",
"Renumber All Nodes by Execution Order (Alt+R)": "Rinumera i nodi per ordine di esecuzione (Alt+R)",
"Clear Cancelled & Failed": "Cancella annullati e falliti",
"Clear All History": "Cancella tutta la cronologia",
"Filter by text, images, nodes...": "Filtra per testo, immagini, nodi...",
"Clear & Close Search": "Cancella e chiudi ricerca",
"Scroll to Top": "Torna in cima",
"Batch of #": "Lotto di #",
"Go Back": "Indietro",
"Go Back to Queue": "Torna alla coda",
"Outputs of #": "Uscite di #",
"No separate intermediate outputs found.": "Nessuna uscita intermedia separata trovata.",
"Cancel All Pending": "Annulla tutti i task in attesa",
"Pending Queue:": "In attesa:",
"tasks": "attività",
"Download Object": "Scarica elemento",
"Copy to Clipboard": "Copia negli appunti",
"Download JSON": "Scarica JSON",
"Delete Card (Hold Ctrl to delete file from disk)": "Elimina scheda (Ctrl per eliminare dal disco)",
"Delete Card": "Elimina scheda",
"Delete File": "Elimina file",
"Delete Files": "Elimina file",
"Show Node": "Mostra nodo",
"Show Stopped/Failed Node": "Mostra nodo fermato o fallito",
"View all intermediate outputs": "Visualizza tutte le uscite intermedie",
"Delete Card (Ctrl+Click to delete from disk)": "Elimina scheda (Ctrl+Clic per cancellare dal disco)",
"Ctrl+Click again to delete file from DISK / TRASH": "Ctrl+Clic di nuovo per eliminare definitivamente",
"Click again to confirm removing card": "Clicca di nuovo per confermare l'eliminazione",
"Remove from batch (Hold Ctrl to delete file from disk)": "Rimuovi dal lotto (Ctrl per eliminare dal disco)",
"Click again to confirm removing from batch": "Clicca di nuovo per rimuovere dal lotto",
"Delete node output (Hold Ctrl to delete file from disk)": "Elimina uscita nodo (Ctrl per eliminare dal disco)",
"Ctrl+Click again to delete this node's files from DISK / TRASH": "Ctrl+Clic di nuovo per eliminare i file dal disco",
"Click again to confirm removing output": "Clicca di nuovo per confermare l'eliminazione",
"Moved file to Recycle Bin.": "File spostato nel cestino.",
"Permanently deleted file.": "File eliminato definitivamente.",
"Cancelled": "Annullato",
"Error": "Errore",
"Warning": "Avviso",
"Cancel Queued Task": "Annulla task in coda",
"Interrupt Execution": "Interrompi esecuzione",
"Click to read full text": "Clicca per leggere il testo completo",
"No Outputs": "Nessuna uscita",
"Processing...": "Elaborazione...",
"Finishing...": "Completamento...",
"No workflow metadata found in this file.": "Nessun metadato di workflow trovato in questo file.",
"Open Folder": "Apri cartella",
"Open Location": "Apri percorso",
"View all images in this batch": "Mostra tutte le immagini del lotto",
"No nodes found on canvas to renumber.": "Nessun nodo trovato da rinumerare.",
"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?": "La rinumerazione riscriverà gli ID di tutti i nodi nell'ordine di esecuzione.\n\nQuesto risolverà le uscite intermedie mostrate come risultato finale, ma potrebbe interferire con script esterni.\n\nVuoi continuare?",
"Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).": "Rinumerati {count} nodi in ordine di esecuzione. Ultimo nodo: #{id} ({name}).",
"Failed to renumber nodes: ": "Errore nella rinumerazione dei nodi: ",
"Please select a node first to change its ID.": "Seleziona prima un nodo per cambiarne l'ID.",
"Enter new ID for node #{id} ({name}):": "Inserisci il nuovo ID per il nodo #{id} ({name}):",
"Please enter a valid positive number.": "Inserisci un numero intero positivo valido.",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "L'ID #{id} è già utilizzato da \"{name}\". Vuoi scambiarli?",
"Assigned node ID #{id} to \"{name}\".": "Assegnato ID #{id} al nodo \"{name}\".",
"Failed to set node ID: ": "Errore nell'impostazione dell'ID: ",
"Video playback. Press Esc to close.": "Riproduzione video. Premi Esc per chiudere.",
"Click image to zoom (100%/Fit) | Shift+Click another card to compare.": "Clicca per ingrandire (100%/Adatta) | Shift+Clic su un'altra scheda per confrontare.",
"Toggle Loop": "Riproduzione continua",
"Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.": "Proporzioni diverse ({wA}x{hA} vs {wB}x{hB}). Le immagini devono avere lo stesso formato per essere confrontate.",
"Click image to zoom | Drag image to compare slider | Esc to close.": "Clicca per ingrandire | Trascina per confrontare | Esc per chiudere.",
"Copy": "Copia",
"Copied!": "Copiato!",
"Text Output": "Uscita testo",
"Text Output #": "Uscita testo #",
"Raw Text": "Testo non formattato",
"{wc} words • {chars} chars": "{wc} parole • {chars} car.",
"3D Model": "Modello 3D",
"Loading 3D Asset...": "Caricamento modello 3D...",
"Exporting {format}...": "Esportazione in {format}...",
"Failed to load 3D engine.": "Caricamento motore 3D non riuscito.",
"Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.": "Impossibile scaricare le librerie 3D. Verifica la connessione Internet.",
"Background Color": "Colore di sfondo",
"Up Direction": "Direzione verso l'alto",
"Material Mode": "Modalità materiale",
"Camera Type": "Tipo di telecamera",
"FOV": "Campo visivo (FOV)",
"Light Intensity": "Intensità luce",
"Original (Y-Up)": "Originale (Y verso l'alto)",
"Z-Up (+90°)": "Z verso l'alto (+90°)",
"Inverted (-90°)": "Invertito (-90°)",
"Original / Textured": "Originale / Texture",
"Clay / Studio Matte": "Argilla / Opaco",
"Normals": "Normali",
"Perspective": "Prospettiva",
"Orthographic": "Ortografica",
"Grid": "Griglia",
"Auto-Rotate": "Rotazione automatica",
"Wireframe": "Reticolo / Wireframe",
"Reset View": "Ripristina visuale",
"Settings": "Impostazioni",
"Export As...": "Esporta come...",
"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."
}
+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?": "ノードの再番号付けは、トポロジカルな実行順序ですべてのIDを書き換えます。\n\nこれにより中間出力が最終結果として表示される問題が修正されますが、特定のIDに依存するスクリプトが動作しなくなる可能性があります。\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.": "IDを変更するノードを1つ選択してください。",
"Enter new ID for node #{id} ({name}):": "ノード #{id} ({name}) の新しいIDを入力してください:",
"Please enter a valid positive number.": "正の整数を入力してください。",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "ノードID #{id} はすでに \"{name}\" で使用されています。IDを入れ替えますか?",
"Assigned node ID #{id} to \"{name}\".": "ノード \"{name}\" に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キーで閉じる",
"Copy": "コピー",
"Copied!": "コピーしました!",
"Text Output": "テキスト出力",
"Text Output #": "テキスト出力 #",
"Raw Text": "プレーンテキスト",
"{wc} words • {chars} chars": "{wc} 単語 • {chars} 文字",
"3D Model": "3Dモデル",
"Loading 3D Asset...": "3Dアセットを読み込み中...",
"Exporting {format}...": "{format} 形式でエクスポート中...",
"Failed to load 3D engine.": "3Dエンジンの読み込みに失敗しました。",
"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": "透視投影 (Perspective)",
"Orthographic": "正投影 (Orthographic)",
"Grid": "グリッド",
"Auto-Rotate": "自動回転",
"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...": "選択したノード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 などのショートカットが動作するようにします。テキスト入力欄での入力には影響しません。"
}
+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?": "노드 재번호 매기기는 실행 순서에 따라 캔버스의 모든 노드 ID를 다시 씁니다.\n\n중간 노드 출력이 최종 결과로 표시되는 문제를 해결하지만, 특정 ID에 의존하는 외부 스크립트가 손상될 수 있습니다.\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.": "ID를 변경할 노드를 먼저 선택하세요.",
"Enter new ID for node #{id} ({name}):": "노드 #{id} ({name})의 새 ID 입력:",
"Please enter a valid positive number.": "올바른 양의 정수를 입력하세요.",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "노드 ID #{id}는 이미 \"{name}\"에서 사용 중입니다. 두 노드의 ID를 맞바꾸시겠습니까?",
"Assigned node ID #{id} to \"{name}\".": "\"{name}\"에 노드 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} vs {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...": "3D 에셋 로드 중...",
"Exporting {format}...": "{format} 내보내는 중...",
"Failed to load 3D engine.": "3D 엔진 로드 실패.",
"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": "와이어프레임",
"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...": "선택한 노드 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 같은 단축키가 작동합니다. 텍스트 입력란에서의 입력에는 영향을 주지 않습니다."
}
+118
View File
@@ -0,0 +1,118 @@
{
"Queue": "Fila",
"Search History": "Pesquisar histórico",
"Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)": "Ignorar/Incluir nó(s) selecionado(s) na fila (Ctrl+Q)",
"No nodes selected. Click a node on the canvas first to toggle ignore.": "Nenhum nó selecionado. Clique primeiro em um nó na tela.",
"Renumber All Nodes by Execution Order (Alt+R)": "Renumerar todos os nós por ordem de execução (Alt+R)",
"Clear Cancelled & Failed": "Limpar cancelados e com falha",
"Clear All History": "Limpar todo o histórico",
"Filter by text, images, nodes...": "Filtrar por texto, imagens, nós...",
"Clear & Close Search": "Limpar e fechar busca",
"Scroll to Top": "Voltar ao topo",
"Batch of #": "Lote de #",
"Go Back": "Voltar",
"Go Back to Queue": "Voltar para a fila",
"Outputs of #": "Saídas de #",
"No separate intermediate outputs found.": "Nenhuma saída intermediária separada encontrada.",
"Cancel All Pending": "Cancelar todas as tarefas pendentes",
"Pending Queue:": "Fila pendente:",
"tasks": "tarefas",
"Download Object": "Baixar objeto",
"Copy to Clipboard": "Copiar para a área de transferência",
"Download JSON": "Baixar JSON",
"Delete Card (Hold Ctrl to delete file from disk)": "Excluir cartão (Ctrl para apagar do disco)",
"Delete Card": "Excluir cartão",
"Delete File": "Excluir arquivo",
"Delete Files": "Excluir arquivos",
"Show Node": "Mostrar nó",
"Show Stopped/Failed Node": "Mostrar nó interrompido ou com falha",
"View all intermediate outputs": "Ver todas as saídas intermediárias",
"Delete Card (Ctrl+Click to delete from disk)": "Excluir cartão (Ctrl+Clique para apagar do disco)",
"Ctrl+Click again to delete file from DISK / TRASH": "Ctrl+Clique novamente para excluir permanentemente",
"Click again to confirm removing card": "Clique novamente para confirmar a remoção",
"Remove from batch (Hold Ctrl to delete file from disk)": "Remover do lote (Ctrl para apagar do disco)",
"Click again to confirm removing from batch": "Clique novamente para remover do lote",
"Delete node output (Hold Ctrl to delete file from disk)": "Excluir saída (Ctrl para apagar do disco)",
"Ctrl+Click again to delete this node's files from DISK / TRASH": "Ctrl+Clique novamente para apagar do disco",
"Click again to confirm removing output": "Clique novamente para confirmar a exclusão",
"Moved file to Recycle Bin.": "Arquivo movido para a Lixeira.",
"Permanently deleted file.": "Arquivo excluído permanentemente.",
"Cancelled": "Cancelado",
"Error": "Erro",
"Warning": "Aviso",
"Cancel Queued Task": "Cancelar tarefa da fila",
"Interrupt Execution": "Interromper execução",
"Click to read full text": "Clique para ler o texto completo",
"No Outputs": "Sem saídas",
"Processing...": "Processando...",
"Finishing...": "Finalizando...",
"No workflow metadata found in this file.": "Nenhum metadado de fluxo de trabalho encontrado.",
"Open Folder": "Abrir pasta",
"Open Location": "Abrir local do arquivo",
"View all images in this batch": "Ver todas as imagens deste lote",
"No nodes found on canvas to renumber.": "Nenhum nó encontrado na tela para renumerar.",
"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?": "A renumeração reescreverá os IDs de todos os nós de acordo com a ordem de execução.\n\nIsso corrige saídas intermediárias mostradas como resultado final, mas pode interferir em scripts externos.\n\nDeseja continuar?",
"Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).": "{count} nós renumerados por ordem de execução. Último nó: #{id} ({name}).",
"Failed to renumber nodes: ": "Falha ao renumerar nós: ",
"Please select a node first to change its ID.": "Selecione primeiro um nó para alterar o ID.",
"Enter new ID for node #{id} ({name}):": "Digite o novo ID para o nó #{id} ({name}):",
"Please enter a valid positive number.": "Insira um número inteiro positivo válido.",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "O ID #{id} já está em uso por \"{name}\". Deseja inverter os IDs?",
"Assigned node ID #{id} to \"{name}\".": "ID #{id} atribuído ao nó \"{name}\".",
"Failed to set node ID: ": "Falha ao definir o ID do nó: ",
"Video playback. Press Esc to close.": "Reprodução de vídeo. Pressione Esc para fechar.",
"Click image to zoom (100%/Fit) | Shift+Click another card to compare.": "Clique na imagem para zoom (100%/Ajustar) | Shift+Clique em outro cartão para comparar.",
"Toggle Loop": "Repetição contínua",
"Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.": "Proporção incompatível ({wA}x{hA} vs {wB}x{hB}). Imagens devem ter o mesmo formato para comparação.",
"Click image to zoom | Drag image to compare slider | Esc to close.": "Clique para zoom | Arraste para comparar | Esc para fechar.",
"Copy": "Copiar",
"Copied!": "Copiado!",
"Text Output": "Saída de texto",
"Text Output #": "Saída de texto #",
"Raw Text": "Texto puro",
"{wc} words • {chars} chars": "{wc} palavras • {chars} car.",
"3D Model": "Modelo 3D",
"Loading 3D Asset...": "Carregando modelo 3D...",
"Exporting {format}...": "Exportando para {format}...",
"Failed to load 3D engine.": "Falha ao carregar o motor 3D.",
"Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.": "Não foi possível baixar bibliotecas 3D. Verifique sua conexão com a Internet.",
"Background Color": "Cor de fundo",
"Up Direction": "Eixo superior",
"Material Mode": "Modo de material",
"Camera Type": "Tipo de câmera",
"FOV": "Campo de visão (FOV)",
"Light Intensity": "Intensidade da luz",
"Original (Y-Up)": "Original (Y para cima)",
"Z-Up (+90°)": "Z para cima (+90°)",
"Inverted (-90°)": "Invertido (-90°)",
"Original / Textured": "Original / Texturizado",
"Clay / Studio Matte": "Argila / Fosco",
"Normals": "Normais",
"Perspective": "Perspectiva",
"Orthographic": "Ortográfica",
"Grid": "Grade",
"Auto-Rotate": "Rotação automática",
"Wireframe": "Aramado",
"Reset View": "Redefinir visão",
"Settings": "Configurações",
"Export As...": "Exportar como...",
"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."
}
+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?": "Перенумерация перезапишет ID всех нод на холсте по порядку выполнения.\n\nЭто исправит отображение промежуточных нод как финального результата, но может нарушить работу внешних скриптов, зависящих от конкретных ID.\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.": "Сначала выберите ноду для изменения её ID.",
"Enter new ID for node #{id} ({name}):": "Введите новый ID для ноды #{id} ({name}):",
"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 ноды: ",
"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...": "Загрузка 3D-модели...",
"Exporting {format}...": "Экспорт в {format}...",
"Failed to load 3D engine.": "Не удалось загрузить 3D-движок.",
"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. Убедитесь, что компьютер подключен к интернету, или проверьте, не блокирует ли флаг <code>--disable-api-nodes</code> сетевые запросы.",
"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": "Каркас",
"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...": "Задать 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 работают при любой активной раскладке. Ввод текста в полях не затрагивается."
}
+118
View File
@@ -0,0 +1,118 @@
{
"Queue": "Kuyruk",
"Search History": "Geçmişte ara",
"Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)": "Seçili düğümleri kuyrukta yoksay/etkinleştir (Ctrl+Q)",
"No nodes selected. Click a node on the canvas first to toggle ignore.": "Düğüm seçilmedi. Önce tuvalde bir düğüme tıklayın.",
"Renumber All Nodes by Execution Order (Alt+R)": "Düğümleri çalıştırma sırasına göre yeniden numaralandır (Alt+R)",
"Clear Cancelled & Failed": "İptal edilenleri ve hatalıları temizle",
"Clear All History": "Tüm geçmişi temizle",
"Filter by text, images, nodes...": "Metin, görsel, düğüme göre filtrele...",
"Clear & Close Search": "Aramayı temizle ve kapat",
"Scroll to Top": "Yukarı kaydır",
"Batch of #": "Toplu işlem #",
"Go Back": "Geri",
"Go Back to Queue": "Kuyruğa dön",
"Outputs of #": "Çıktıları #",
"No separate intermediate outputs found.": "Ayrı bir ara çıktı bulunamadı.",
"Cancel All Pending": "Bekleyen tüm görevleri iptal et",
"Pending Queue:": "Bekleyen kuyruk:",
"tasks": "görev",
"Download Object": "Dosyayı indir",
"Copy to Clipboard": "Panoya kopyala",
"Download JSON": "JSON indir",
"Delete Card (Hold Ctrl to delete file from disk)": "Kartı sil (Diskten silmek için Ctrl'ye basılı tutun)",
"Delete Card": "Kartı sil",
"Delete File": "Dosyayı sil",
"Delete Files": "Dosyaları sil",
"Show Node": "Düğümü göster",
"Show Stopped/Failed Node": "Durdurulan/hatalı düğümü göster",
"View all intermediate outputs": "Tüm ara çıktıları görüntüle",
"Delete Card (Ctrl+Click to delete from disk)": "Kartı sil (Diskten silmek için Ctrl+Tıklama)",
"Ctrl+Click again to delete file from DISK / TRASH": "Dosyayı diskten tamamen silmek için tekrar Ctrl+Tıklama yapın",
"Click again to confirm removing card": "Kaldırmayı onaylamak için tekrar tıklayın",
"Remove from batch (Hold Ctrl to delete file from disk)": "Gruptan çıkar (Diskten silmek için Ctrl)",
"Click again to confirm removing from batch": "Gruptan çıkarmayı onaylamak için tekrar tıklayın",
"Delete node output (Hold Ctrl to delete file from disk)": "Çıktıyı sil (Diskten silmek için Ctrl)",
"Ctrl+Click again to delete this node's files from DISK / TRASH": "Bu düğümün dosyalarını diskten silmek için tekrar Ctrl+Tıklayın",
"Click again to confirm removing output": "Çıktıyı kaldırmayı onaylamak için tekrar tıklayın",
"Moved file to Recycle Bin.": "Dosya Geri Dönüşüm Kutusu'na taşındı.",
"Permanently deleted file.": "Dosya kalıcı olarak silindi.",
"Cancelled": "İptal edildi",
"Error": "Hata",
"Warning": "Uyarı",
"Cancel Queued Task": "Kuyruktaki görevi iptal et",
"Interrupt Execution": "Çalıştırmayı durdur",
"Click to read full text": "Metnin tamamını okumak için tıklayın",
"No Outputs": "Çıktı yok",
"Processing...": "İşleniyor...",
"Finishing...": "Tamamlanıyor...",
"No workflow metadata found in this file.": "Bu dosyada iş akışı meta verisi bulunamadı.",
"Open Folder": "Klasörü aç",
"Open Location": "Dosya konumunu aç",
"View all images in this batch": "Bu gruptaki tüm resimleri görüntüle",
"No nodes found on canvas to renumber.": "Tuvalde numaralandırılacak düğüm bulunamadı.",
"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?": "Yeniden numaralandırma, tüm düğüm kimliklerini çalıştırma sırasına göre yeniden yazar.\n\nBu işlem ara çıktıların nihai sonuç olarak görünmesini düzeltir ancak belirli kimliklere bağımlı harici betikleri bozabilir.\n\nDevam etmek istiyor musunuz?",
"Renumbered {count} nodes by execution order. Latest node: #{id} ({name}).": "{count} düğüm çalıştırma sırasına göre numaralandırıldı. Son düğüm: #{id} ({name}).",
"Failed to renumber nodes: ": "Düğümler yeniden numaralandırılamadı: ",
"Please select a node first to change its ID.": "Kimliğini değiştirmek için lütfen önce bir düğüm seçin.",
"Enter new ID for node #{id} ({name}):": "#{id} ({name}) düğümü için yeni kimlik girin:",
"Please enter a valid positive number.": "Lütfen geçerli bir pozitif tamsayı girin.",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "#{id} kimliği zaten \"{name}\" tarafından kullanılıyor. Değiştirilsin mi?",
"Assigned node ID #{id} to \"{name}\".": "\"{name}\" düğümüne #{id} kimliği atandı.",
"Failed to set node ID: ": "Düğüm kimliği ayarlanamadı: ",
"Video playback. Press Esc to close.": "Video oynatılıyor. Kapatmak için Esc'ye basın.",
"Click image to zoom (100%/Fit) | Shift+Click another card to compare.": "Yakınlaştırmak için tıkla (100%/Sığdır) | Karşılaştırmak için başka bir karta Shift+Tıkla.",
"Toggle Loop": "Döngüyü aç/kapat",
"Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.": "En-boy oranı uyumsuzluğu ({wA}x{hA} ile {wB}x{hB}). Karşılaştırma için görseller aynı oranda olmalıdır.",
"Click image to zoom | Drag image to compare slider | Esc to close.": "Yakınlaştırmak için tıkla | Karşılaştırmak için kaydır | Kapatmak için Esc.",
"Copy": "Kopyala",
"Copied!": "Kopyalandı!",
"Text Output": "Metin çıktısı",
"Text Output #": "Metin çıktısı #",
"Raw Text": "Ham metin",
"{wc} words • {chars} chars": "{wc} kelime • {chars} karakter",
"3D Model": "3D Model",
"Loading 3D Asset...": "3D model yükleniyor...",
"Exporting {format}...": "{format} formatında dışa aktarılıyor...",
"Failed to load 3D engine.": "3D motoru yüklenemedi.",
"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 kütüphaneleri indirilemedi. İnternet bağlantınızı kontrol edin.",
"Background Color": "Arka plan rengi",
"Up Direction": "Yukarı yön",
"Material Mode": "Materyal modu",
"Camera Type": "Kamera türü",
"FOV": "Görüş açısı (FOV)",
"Light Intensity": "Işık yoğunluğu",
"Original (Y-Up)": "Orijinal (Y yukarı)",
"Z-Up (+90°)": "Z yukarı (+90°)",
"Inverted (-90°)": "Ters (-90°)",
"Original / Textured": "Orijinal / Dokulu",
"Clay / Studio Matte": "Kil / Mat",
"Normals": "Normaller",
"Perspective": "Perspektif",
"Orthographic": "Ortografik",
"Grid": "Izgara",
"Auto-Rotate": "Otomatik döndür",
"Wireframe": "Tel kafes",
"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",
"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."
}
+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?": "重新編號將按拓撲執行順序重寫畫布上的所有節點 ID。\n\n這將修復中間節點輸出顯示為最終結果的問題,但可能會影響依賴特定節點 ID 的外部腳本。\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.": "請先選擇一個節點以更改其 ID。",
"Enter new ID for node #{id} ({name}):": "輸入節點 #{id} ({name}) 的新 ID:",
"Please enter a valid positive number.": "請輸入有效的正整數。",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "節點 ID #{id} 已被 \"{name}\" 使用。是否對調它們的 ID?",
"Assigned node ID #{id} to \"{name}\".": "已將節點 ID #{id} 分配給 \"{name}\"。",
"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 關閉。",
"Copy": "複製",
"Copied!": "已複製!",
"Text Output": "文字輸出",
"Text Output #": "文字輸出 #",
"Raw Text": "原始文字",
"{wc} words • {chars} chars": "{wc} 個詞 • {chars} 個字元",
"3D Model": "3D 模型",
"Loading 3D Asset...": "正在載入 3D 資產...",
"Exporting {format}...": "正在匯出 {format}...",
"Failed to load 3D engine.": "載入 3D 引擎失敗。",
"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 函式庫。請確保連線正常,或檢查是否啟用了 <code>--disable-api-nodes</code>。",
"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": "線框",
"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...": "設定選取節點 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 等快速鍵也能正常運作。在文字欄位中輸入不受影響。"
}
+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?": "重新编号将按拓扑执行顺序重写画布上的所有节点 ID。\n\n这将修复中间节点输出被显示为最终结果的问题,但可能会破坏依赖于特定节点 ID 的外部脚本。\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.": "请先选择一个节点以更改其 ID。",
"Enter new ID for node #{id} ({name}):": "输入节点 #{id} ({name}) 的新 ID:",
"Please enter a valid positive number.": "请输入有效的正整数。",
"Node ID #{id} is already used by \"{name}\". Swap their IDs?": "节点 ID #{id} 已被 \"{name}\" 使用。是否交换它们的 ID?",
"Assigned node ID #{id} to \"{name}\".": "已将节点 ID #{id} 分配给 \"{name}\"。",
"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 关闭。",
"Copy": "复制",
"Copied!": "已复制!",
"Text Output": "文本输出",
"Text Output #": "文本输出 #",
"Raw Text": "原始文本",
"{wc} words • {chars} chars": "{wc} 个词 • {chars} 个字符",
"3D Model": "3D 模型",
"Loading 3D Asset...": "正在加载 3D 资产...",
"Exporting {format}...": "正在导出 {format}...",
"Failed to load 3D engine.": "加载 3D 引擎失败。",
"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 库。请确保网络连接正常,或检查是否开启了 <code>--disable-api-nodes</code>。",
"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": "线框",
"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...": "设置所选节点 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) {
+324 -135
View File
@@ -2,125 +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;
@@ -131,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();
@@ -155,35 +49,269 @@ 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";
const checkAndDisableIfSidebar = (val) => {
if (isSyncing) return;
if (val === "Sidebar") {
const isClassicEnabled = getSetting(SettingIds.CLASSIC_LAYOUT, false);
if (isClassicEnabled) {
isSyncing = true;
try {
setSetting(SettingIds.CLASSIC_LAYOUT, false);
applyClassicLayout(false, false);
} finally {
isSyncing = false;
}
}
}
};
const cleanups = [];
const attachNativeOnChange = (settingObj) => {
if (!settingObj || settingObj._classicWatcherAttached) return;
settingObj._classicWatcherAttached = true;
const origOnChange = settingObj.onChange;
settingObj.onChange = (val) => {
if (origOnChange) origOnChange(val);
checkAndDisableIfSidebar(val);
};
cleanups.push(() => {
settingObj.onChange = origOnChange;
delete settingObj._classicWatcherAttached;
});
};
attachNativeOnChange(app.ui?.settings?.settings?.[TARGET_SETTING]);
attachNativeOnChange(app.extensionManager?.setting?.getSetting?.(TARGET_SETTING));
attachNativeOnChange(app.extensionManager?.setting?.settings?.[TARGET_SETTING]);
const settingStore = app.extensionManager?.setting?.settingStore;
if (settingStore?.$subscribe) {
const unsubscribe = settingStore.$subscribe(() => {
const val = getSetting(TARGET_SETTING);
checkAndDisableIfSidebar(val);
});
cleanups.push(unsubscribe);
}
if (app.ui?.settings?.addSetting) {
const origAdd = app.ui.settings.addSetting.bind(app.ui.settings);
app.ui.settings.addSetting = (setting) => {
if (setting?.id === TARGET_SETTING) {
attachNativeOnChange(setting);
}
return origAdd(setting);
};
cleanups.push(() => {
app.ui.settings.addSetting = origAdd;
});
}
if (app.ui?.settings?.setSettingValue) {
const origSetSettingValue = app.ui.settings.setSettingValue.bind(app.ui.settings);
app.ui.settings.setSettingValue = (id, value) => {
origSetSettingValue(id, value);
if (id === TARGET_SETTING) checkAndDisableIfSidebar(value);
};
cleanups.push(() => {
app.ui.settings.setSettingValue = origSetSettingValue;
});
}
if (app.extensionManager?.setting?.set) {
const origExtSet = app.extensionManager.setting.set.bind(app.extensionManager.setting);
app.extensionManager.setting.set = (id, value) => {
origExtSet(id, value);
if (id === TARGET_SETTING) checkAndDisableIfSidebar(value);
};
cleanups.push(() => {
app.extensionManager.setting.set = origExtSet;
});
}
return () => {
cleanups.forEach(fn => fn());
};
}
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()
}
],
@@ -192,7 +320,7 @@ app.registerExtension({
return [
null,
{
content: "Set Node ID...",
content: t("Set Node ID..."),
callback: () => setManualNodeId(node)
}
];
@@ -202,7 +330,7 @@ app.registerExtension({
return [
null,
{
content: "Renumber All Nodes (Execution Order)",
content: t("Renumber All Nodes (Execution Order)"),
callback: () => renumberNodesTopologically()
}
];
@@ -224,11 +352,19 @@ app.registerExtension({
async setup() {
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
await initI18n();
const tabsPos = getSetting("Comfy.Workflow.WorkflowTabsPosition");
let isClassicLayoutEnabled = getSetting(SettingIds.CLASSIC_LAYOUT, false);
if (isClassicLayoutEnabled && tabsPos === "Sidebar") {
isClassicLayoutEnabled = false;
setSetting(SettingIds.CLASSIC_LAYOUT, false);
}
const isClassicLayoutEnabled = app.ui.settings.getSettingValue(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);
}
@@ -238,15 +374,16 @@ app.registerExtension({
setupPropertiesPanelToggleFix();
injectStyles();
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();
@@ -260,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: "Queue",
tooltip: "Comfy Queue (Q)",
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();
});
},
@@ -276,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);
@@ -286,4 +475,4 @@ app.registerExtension({
app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar");
}
}
});
});
+65 -91
View File
@@ -1,10 +1,11 @@
import { app } from "/scripts/app.js";
import { PromptStatus } from "../core/constants.js";
import { t } from "../core/i18n.js";
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";
@@ -96,16 +97,10 @@ export function syncCardButtonVisibility(cardObj, state) {
if (wf) state.workflow = wf;
}
if (!wf) {
alert("No workflow metadata found in this file.");
alert(t("No workflow metadata found in this file."));
return;
}
const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `workflow_${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");
@@ -121,8 +116,8 @@ export function syncCardButtonVisibility(cardObj, state) {
cardObj.btnDelLabel.style.display = hasRealDiskFiles ? "" : "none";
}
cardObj.btnDel.title = hasRealDiskFiles
? "Delete Card (Hold Ctrl to delete file from disk)"
: "Delete Card";
? t("Delete Card (Hold Ctrl to delete file from disk)")
: t("Delete Card");
}
if (cardObj.btnFocus) {
@@ -130,7 +125,7 @@ export function syncCardButtonVisibility(cardObj, state) {
if (nodeId) {
cardObj.btnFocus.style.removeProperty("display");
cardObj.btnFocus.style.display = "inline-flex";
cardObj.btnFocus.title = (!currentImg && state.activeNodeId) ? "Show Stopped/Failed Node" : "Show Node";
cardObj.btnFocus.title = (!currentImg && state.activeNodeId) ? t("Show Stopped/Failed Node") : t("Show Node");
cardObj.btnFocus.onclick = (ev) => {
ev.stopPropagation();
centerAndSelectCanvasNode(nodeId);
@@ -303,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;
}
@@ -322,7 +315,7 @@ export function updateCardProgressTargeted(cardObj, progress, activeNodeName, sh
}
if (cardObj.statusText && showWorkingNode) {
cardObj.statusText.style.display = "block";
const nodeText = activeNodeName ? (activeNodeName === "Finishing..." ? "Finishing..." : `[${activeNodeName}]`) : "Processing...";
const nodeText = activeNodeName ? (activeNodeName === "Finishing..." ? t("Finishing...") : `[${activeNodeName}]`) : t("Processing...");
cardObj.statusText.textContent = `${nodeText} ${progress}%`;
}
@@ -385,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"
});
@@ -408,23 +401,23 @@ export function getOrCreateCard(state, callbacks = {}) {
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
btnImg.title = t("Download Object");
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = "Copy to Clipboard";
btnCopy.title = t("Copy to Clipboard");
btnCopy.style.display = "none";
const btnJson = document.createElement("span");
btnJson.className = "pi pi-file comfy-sidebar-card-action-btn";
btnJson.title = "Download JSON";
btnJson.title = t("Download JSON");
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Delete Card (Hold Ctrl to delete file from disk)";
btnDel.title = t("Delete Card (Hold Ctrl to delete file from disk)");
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDelLabel.textContent = t("Delete File");
btnDel.appendChild(btnDelLabel);
const leftHoverPanel = document.createElement("div");
@@ -435,11 +428,11 @@ export function getOrCreateCard(state, callbacks = {}) {
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
btnFocus.title = "Show Node";
btnFocus.title = t("Show Node");
const leftHoverBtn = document.createElement("span");
leftHoverBtn.className = "pi pi-images comfy-sidebar-card-action-btn";
leftHoverBtn.title = "View all intermediate outputs";
leftHoverBtn.title = t("View all intermediate outputs");
hoverPanel.append(btnImg, btnJson, btnDel);
leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn);
@@ -502,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 = "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
? "Ctrl+Click again to delete file from DISK / TRASH"
: "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;
@@ -600,7 +576,7 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
color: "#000",
zIndex: "30"
});
cardObj.statusBadge.textContent = "Cancelled";
cardObj.statusBadge.textContent = t("Cancelled");
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
} else if (state.status === PromptStatus.ERROR) {
Object.assign(cardObj.statusBadge.style, {
@@ -609,7 +585,7 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
color: "#fff",
zIndex: "30"
});
cardObj.statusBadge.textContent = "Error";
cardObj.statusBadge.textContent = t("Error");
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
} else {
cardObj.statusBadge.style.display = "none";
@@ -617,14 +593,14 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
if (state.status === PromptStatus.PENDING && !showPendingSummary) {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.title = "Cancel Queued Task";
cardObj.cancelBtn.title = t("Cancel Queued Task");
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
cancelPendingTask(state.pid);
};
} else if (state.status === PromptStatus.ACTIVE) {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.title = "Interrupt Execution";
cardObj.cancelBtn.title = t("Interrupt Execution");
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
interruptActive();
@@ -642,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;
}
@@ -663,7 +637,7 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
if (state.texts && state.texts.length > 0) {
const fullText = state.texts.join("\n\n");
cardObj.placeholder.textContent = fullText;
cardObj.placeholder.title = "Click to read full text";
cardObj.placeholder.title = t("Click to read full text");
cardObj.placeholder.className = "comfy-sidebar-text-clamp";
cardObj.placeholder.style.cssText = "";
cardObj.placeholder.style.display = "-webkit-box";
@@ -673,7 +647,7 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
showFullscreenPreview([{ text: fullText, pid: state.pid }], false, state.pid);
};
} else {
cardObj.placeholder.textContent = state.progressText || "No Outputs";
cardObj.placeholder.textContent = state.progressText || t("No Outputs");
cardObj.placeholder.className = "";
Object.assign(cardObj.placeholder.style, {
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px",
@@ -686,7 +660,7 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
if (state.status === PromptStatus.ACTIVE) {
if (showWorkingNode) {
cardObj.statusText.style.display = "block";
const nodeText = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]`) : "Processing...";
const nodeText = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? t("Finishing...") : `[${state.activeNodeName}]`) : t("Processing...");
cardObj.statusText.textContent = `${nodeText}${state.progress ? ` ${state.progress}%` : ""}`;
} else {
cardObj.statusText.style.display = "none";
+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;
}
}
+14 -6
View File
@@ -4,6 +4,8 @@ import { stopAllAudioPlayback, isAudioViewerOpen, setCurrentlyPlayingAudio, getC
import { showFullscreenPreview } from "../utils/comparison.js";
import { store } from "../core/store.js";
import { PromptStatus } from "../core/constants.js";
import { t } from "../core/i18n.js";
import { deleteHistoryItem } from "../queue/queueService.js";
export function findWorkflowBackgroundThumbnail(state, randParam = "") {
if (!state) return null;
@@ -371,7 +373,7 @@ export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state)
const openHint = document.createElement("div");
openHint.className = "comfy-sidebar-file-subtext";
openHint.innerHTML = `<i class="pi pi-external-link" style="font-size: 9px;"></i><span>Open Folder</span>`;
openHint.innerHTML = `<i class="pi pi-external-link" style="font-size: 9px;"></i><span>${t("Open Folder")}</span>`;
bottomRow.append(folderInfo, openHint);
@@ -454,7 +456,7 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
if (!ctrlOverlay) {
ctrlOverlay = document.createElement("div");
ctrlOverlay.className = "comfy-sidebar-ctrl-overlay";
ctrlOverlay.innerHTML = `<i class="pi pi-folder-open"></i><span>Open Location</span>`;
ctrlOverlay.innerHTML = `<i class="pi pi-folder-open"></i><span>${t("Open Location")}</span>`;
wrapper.appendChild(ctrlOverlay);
}
@@ -583,7 +585,11 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
if (parentState.nodeOutputs) {
removeImageFromNodeOutputs(parentState.nodeOutputs, img);
}
store.updatePrompt(parentPid, parentState);
if ((!parentState.images || parentState.images.length === 0) && parentState.status === PromptStatus.COMPLETED) {
deleteHistoryItem(parentPid);
} else {
store.updatePrompt(parentPid, parentState);
}
}
cardObj.element?.remove();
const remaining = store.ui.cardStack?.querySelectorAll('.comfy-sidebar-card');
@@ -612,13 +618,15 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
return;
}
if ((liveState.texts && liveState.texts.length > 0) || (liveState.status && liveState.status !== PromptStatus.COMPLETED)) {
// If the job is still actively generating/running, keep it
if (liveState.status && liveState.status !== PromptStatus.COMPLETED) {
cardObj.lastImagesSignature = "";
store.updatePrompt(liveState.pid, liveState);
return;
}
store.deletePrompt(liveState.pid);
// Temp image preview was deleted and only intermediate prompt text remains: delete card for good
deleteHistoryItem(liveState.pid);
cardObj.element?.remove();
return;
}
@@ -784,7 +792,7 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
label.className = "comfy-sidebar-batch-label";
label.textContent = `${idx + 1}/${state.images.length}`;
label.style.cursor = "pointer";
label.title = "View all images in this batch";
label.title = t("View all images in this batch");
label.onclick = (ev) => {
ev.stopPropagation();
+202 -175
View File
@@ -1,10 +1,12 @@
import { app } from "/scripts/app.js";
import { PromptStatus, StoreEvents, SettingIds } from "../core/constants.js";
import { store } from "../core/store.js";
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";
@@ -15,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;
@@ -89,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) {
@@ -113,6 +168,8 @@ function updateScrollTopBtnVisibility() {
} else {
scrollToTopBtnEl.style.display = "none";
}
handleQueueHeaderScroll(scrollEl);
}
}
@@ -161,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");
@@ -177,10 +235,10 @@ export function setupSidebarView() {
const searchIcon = document.createElement("span");
searchIcon.className = "pi pi-search comfy-sidebar-icon-btn";
searchIcon.title = "Search History";
searchIcon.title = t("Search History");
const title = document.createElement("h3");
title.textContent = "Queue";
title.textContent = t("Queue");
Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" });
titleGroup.appendChild(searchIcon);
@@ -212,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; }
};
@@ -235,7 +293,7 @@ export function setupSidebarView() {
return btn;
};
const btnToggleIgnore = createInstantBtn("pi pi-eye-slash", "Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)", () => {
const btnToggleIgnore = createInstantBtn("pi pi-eye-slash", t("Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)"), () => {
const raw = app.canvas?.selected_nodes;
const selected = raw instanceof Map || raw instanceof Set
? Array.from(raw.values())
@@ -243,29 +301,29 @@ export function setupSidebarView() {
const selectedNodes = selected.length > 0 ? selected : (app.canvas?.selected_node ? [app.canvas.selected_node] : []);
if (selectedNodes.length === 0) {
showNotification("No nodes selected. Click a node on the canvas first to toggle ignore.", "warn");
showNotification(t("No nodes selected. Click a node on the canvas first to toggle ignore."), "warn");
return;
}
toggleIgnoreActiveNode(() => renderSidebar());
});
const btnRenumber = createInstantBtn("pi pi-sort-numeric-down", "Renumber All Nodes by Execution Order (Alt+R)", async () => {
const btnRenumber = createInstantBtn("pi pi-sort-numeric-down", t("Renumber All Nodes by Execution Order (Alt+R)"), async () => {
await renumberNodesTopologically();
});
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"
});
const btnClearInterrupted = createActionBtn("pi pi-eraser", "Clear Cancelled & Failed", "#ffc107", async () => {
const btnClearInterrupted = createActionBtn("pi pi-eraser", t("Clear Cancelled & Failed"), "#ffc107", async () => {
await clearCancelledOrFailed();
renderSidebar();
});
const btnClearAll = createActionBtn("pi pi-trash", "Clear All History", "#dc3545", async () => {
const btnClearAll = createActionBtn("pi pi-trash", t("Clear All History"), "#dc3545", async () => {
await clearAllHistory();
renderSidebar();
});
@@ -280,22 +338,23 @@ 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");
searchInputIcon.className = "pi pi-search";
Object.assign(searchInputIcon.style, { fontSize: "11px", opacity: "0.5", marginRight: "6px" });
const searchInput = document.createElement("input");
Object.assign(searchInput, { type: "text", placeholder: "Filter by text, images, nodes..." });
Object.assign(searchInput, { type: "text", placeholder: t("Filter by text, images, nodes...") });
Object.assign(searchInput.style, {
flex: "1", background: "transparent", border: "none", outline: "none",
color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0"
});
const clearSearchBtn = document.createElement("span");
clearSearchBtn.className = "pi pi-times comfy-sidebar-icon-btn";
clearSearchBtn.title = "Clear & Close Search";
clearSearchBtn.title = t("Clear & Close Search");
Object.assign(clearSearchBtn.style, { marginLeft: "6px" });
searchContainer.appendChild(searchInputIcon);
@@ -304,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();
@@ -335,27 +399,30 @@ export function setupSidebarView() {
overflowY: "visible",
display: "flex",
flexDirection: "column",
paddingBottom: "28px"
paddingBottom: "46px"
});
container.appendChild(cardStack);
scrollToTopBtnEl = document.createElement("button");
scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn";
scrollToTopBtnEl.title = "Scroll to Top";
scrollToTopBtnEl.title = t("Scroll to Top");
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();
@@ -394,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");
@@ -406,9 +473,9 @@ export function renderSidebar() {
const batchInfo = store.ui.activeSubmenuBatchImages;
if (headerTitle) {
headerTitle.textContent = `Batch of #${batchInfo.pid}`;
headerTitle.textContent = `${t("Batch of #")}${batchInfo.pid}`;
headerTitle.style.cursor = "pointer";
headerTitle.title = "Go Back";
headerTitle.title = t("Go Back");
headerTitle.onclick = () => {
if (batchInfo.parentPromptId) {
store.openOutputsSubmenu(batchInfo.parentPromptId);
@@ -469,7 +536,7 @@ export function renderSidebar() {
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
btnImg.title = t("Download Object");
btnImg.onclick = (ev) => {
ev.stopPropagation();
const a = document.createElement("a");
@@ -480,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 = "Remove from batch (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
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 = "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
? "Ctrl+Click again to delete file from DISK / TRASH"
: "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" ? "Moved file to Recycle Bin." : "Permanently deleted file.";
showNotification(msg, "success", "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);
@@ -565,7 +613,7 @@ export function renderSidebar() {
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = "Copy to Clipboard";
btnCopy.title = t("Copy to Clipboard");
const isCopyable = isImageFormat(img.filename || img.url);
if (!isCopyable) {
btnCopy.style.display = "none";
@@ -587,7 +635,7 @@ export function renderSidebar() {
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
btnFocus.title = "Show Node";
btnFocus.title = t("Show Node");
const nodeId = findNodeIdForImage({ nodeOutputs: batchInfo.nodeOutputs }, img);
if (nodeId) {
btnFocus.onclick = (ev) => {
@@ -633,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;
@@ -649,9 +697,9 @@ export function renderSidebar() {
}
if (headerTitle) {
headerTitle.textContent = `Outputs of #${store.ui.activeSubmenuPromptId}`;
headerTitle.textContent = `${t("Outputs of #")}${store.ui.activeSubmenuPromptId}`;
headerTitle.style.cursor = "pointer";
headerTitle.title = "Go Back to Queue";
headerTitle.title = t("Go Back to Queue");
headerTitle.onclick = () => {
store.closeSubmenu();
resetAllCardHoverStates();
@@ -691,7 +739,7 @@ export function renderSidebar() {
padding: "16px", textAlign: "center", color: "var(--desc-color, #aaa)", fontSize: "12px",
gridColumn: "1 / -1"
});
emptyCard.textContent = "No separate intermediate outputs found.";
emptyCard.textContent = t("No separate intermediate outputs found.");
targetElements.push(emptyCard);
} else {
outputs.forEach((out) => {
@@ -738,7 +786,7 @@ export function renderSidebar() {
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
btnImg.title = t("Download Object");
btnImg.onclick = (ev) => {
ev.stopPropagation();
out.images.forEach(img => {
@@ -751,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 = "Delete node output (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
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 = "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
? "Ctrl+Click again to delete this node's files from DISK / TRASH"
: "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);
@@ -825,7 +854,7 @@ export function renderSidebar() {
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = "Copy to Clipboard";
btnCopy.title = t("Copy to Clipboard");
const firstImg = out.images[0];
const isCopyable = firstImg && isImageFormat(firstImg.filename || firstImg.url);
if (!isCopyable) {
@@ -848,7 +877,7 @@ export function renderSidebar() {
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
btnFocus.title = "Show Node";
btnFocus.title = t("Show Node");
btnFocus.onclick = (ev) => {
ev.stopPropagation();
centerAndSelectCanvasNode(out.nodeId);
@@ -886,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;
}
@@ -901,7 +930,7 @@ export function renderSidebar() {
});
} else {
cardObj.placeholder.style.display = "block";
cardObj.placeholder.textContent = "No Outputs";
cardObj.placeholder.textContent = t("No Outputs");
}
targetElements.push(cardObj.element);
@@ -920,7 +949,7 @@ export function renderSidebar() {
// Main Queue View
if (headerTitle) {
headerTitle.textContent = "Queue";
headerTitle.textContent = t("Queue");
headerTitle.style.cursor = "default";
headerTitle.onclick = null;
}
@@ -955,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.textContent = "Cancel All Pending";
Object.assign(cancelBtn.style, {
background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "4px",
cursor: "pointer", fontSize: "11px", fontWeight: "bold"
});
cancelBtn.className = "comfy-sidebar-cancel-all-btn";
cancelBtn.textContent = t("Cancel All Pending");
cancelBtn.onclick = async () => {
await cancelAllPending();
};
@@ -974,17 +1000,14 @@ export function renderSidebar() {
pCard = { element: el, textDiv };
cardPool.set("pending-summary-card", pCard);
}
pCard.textDiv.textContent = `Pending Queue: ${pendingCount} tasks`;
pCard.textDiv.textContent = `${t("Pending Queue:")} ${pendingCount} ${t("tasks")}`;
bannerElements.push(pCard.element);
} else {
let btnCard = cardPool.get("pending-cancel-all-standalone");
if (!btnCard) {
const btn = document.createElement("button");
btn.textContent = "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.className = "comfy-sidebar-cancel-all-btn";
btn.textContent = t("Cancel All Pending");
btn.onclick = async () => {
await cancelAllPending();
};
@@ -999,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);
@@ -1010,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; });
@@ -1037,4 +1060,8 @@ export function teardownSidebarView() {
renderAnimationFrameId = null;
}
cardPool.clear();
if (scrollToTopBtnEl && scrollToTopBtnEl.parentNode) {
scrollToTopBtnEl.parentNode.removeChild(scrollToTopBtnEl);
}
scrollToTopBtnEl = null;
}
+36 -22
View File
@@ -5,6 +5,7 @@ import { stopAllAudioPlayback } from "../ui/audioController.js";
import { SidebarOverlay } from "./overlay.js";
import { store } from "../core/store.js";
import { PromptStatus } from "../core/constants.js";
import { t } from "../core/i18n.js";
let activeComparisonViewer = null;
let isRuntimePreviewActive = false;
@@ -213,7 +214,7 @@ function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () =>
borderRadius: "4px", padding: "4px 8px", fontSize: "11px", cursor: "pointer"
});
loopBtn.innerHTML = `<i class="pi pi-replay"></i>`;
loopBtn.title = "Toggle Loop";
loopBtn.title = t("Toggle Loop");
const speedSelect = document.createElement("select");
Object.assign(speedSelect.style, {
@@ -327,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;
@@ -344,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();
@@ -366,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"
@@ -397,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");
@@ -491,18 +496,18 @@ 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");
infoText.textContent = isBaseVideo
? "Video playback. Press Esc to close."
: "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
? t("Video playback. Press Esc to close.")
: t("Click image to zoom (100%/Fit) | Shift+Click another card to compare.");
header.appendChild(infoText);
overlay.container.appendChild(header);
@@ -545,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>
`;
@@ -575,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);
@@ -667,12 +681,12 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
const arDiff = Math.abs(arA - arB) / Math.max(arA, arB);
if (arDiff > 0.05) {
infoText.textContent = `Aspect ratio mismatch (${wA}x${hA} vs ${wB}x${hB}). Images must have matching proportions to compare.`;
infoText.textContent = t("Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.", { wA, hA, wB, hB });
infoText.style.color = "#f87171";
setTimeout(() => {
if (infoText) {
infoText.style.color = "#aaa";
infoText.textContent = "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
infoText.textContent = t("Click image to zoom (100%/Fit) | Shift+Click another card to compare.");
}
}, 3500);
@@ -689,7 +703,7 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
if (mediaA) mediaA.style.pointerEvents = "none";
if (mediaB) mediaB.style.pointerEvents = "none";
infoText.style.color = "#aaa";
infoText.textContent = "Click image to zoom | Drag image to compare slider | Esc to close.";
infoText.textContent = t("Click image to zoom | Drag image to compare slider | Esc to close.");
updateSliderPosition(50);
const maxW = Math.max(wA, wB);
@@ -891,7 +905,7 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
mediaA.onload = () => {
hidePlaceholder();
infoText.style.color = "#aaa";
infoText.textContent = "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
infoText.textContent = t("Click image to zoom (100%/Fit) | Shift+Click another card to compare.");
syncImageScales();
};
}
+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);
};
}
+140 -83
View File
@@ -1,8 +1,9 @@
import { app } from "/scripts/app.js";
import { t } from "../core/i18n.js";
import { syncAllNodeBadges } from "../comfy/nodes.js";
export function showNotification(message, type = "success", title = null) {
const summary = title || (type === "error" ? "Error" : type === "warn" ? "Warning" : "Comfy Sidebar");
const summary = title || (type === "error" ? t("Error") : type === "warn" ? t("Warning") : "Comfy Sidebar");
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
@@ -55,16 +56,114 @@ 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("No nodes found on canvas to renumber.", "warn");
showNotification(t("No nodes found on canvas to renumber."), "warn");
return;
}
const confirmed = confirm(
"Renumbering will rewrite all node IDs on the canvas in topological execution order.\n\n" +
"This will fix intermediate node outputs showing as the final result, but may break external scripts that rely on specific node IDs\n\n" +
"Do you want to proceed?"
t("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?")
);
if (!confirmed) return;
@@ -89,58 +188,24 @@ 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(`Renumbered ${sortedNodes.length} nodes by execution order. Latest node: #${lastNode.id} (${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("Failed to renumber nodes: " + err.message, "error");
showNotification(t("Failed to renumber nodes: ") + err.message, "error");
}
}
@@ -152,66 +217,58 @@ export async function setManualNodeId(targetNode = null) {
: (Array.isArray(rawSelected) ? rawSelected : (rawSelected && typeof rawSelected === "object" ? Object.values(rawSelected) : []));
const node = targetNode || selectedList[0] || app.canvas?.current_active_node || null;
if (!node) {
showNotification("Please select a node first to change its ID.", "warn");
showNotification(t("Please select a node first to change its ID."), "warn");
return;
}
const currentId = node.id;
const input = prompt(`Enter new ID for node #${currentId} (${node.title || node.type}):`, String(currentId));
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) {
showNotification("Please enter a valid positive number.", "error");
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(`Node ID #${newId} is already used by "${existingNode.title || existingNode.type}". Swap their IDs?`);
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(`Assigned node ID #${newId} to "${node.title || node.type}".`, "success");
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);
showNotification("Failed to set node ID: " + err.message, "error");
showNotification(t("Failed to set node ID: ") + err.message, "error");
}
}
+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);
}
+46 -35
View File
@@ -1,4 +1,5 @@
import { SidebarOverlay } from "./overlay.js";
import { t } from "../core/i18n.js";
// Global helper for code block copying in Markdown view
window.moonCopyCode = function(button) {
@@ -10,23 +11,23 @@ window.moonCopyCode = function(button) {
const text = codeEl.innerText;
navigator.clipboard.writeText(text).then(() => {
button.innerText = "Copied!";
button.innerText = t("Copied!");
button.style.backgroundColor = "#4CAF50";
button.style.borderColor = "#45a049";
button.style.color = "#ffffff";
setTimeout(() => {
button.innerText = "Copy";
button.innerText = t("Copy");
button.style.backgroundColor = "#333333";
button.style.borderColor = "#555555";
button.style.color = "#aaaaaa";
}, 1500);
}).catch(() => {
button.innerText = "Error";
setTimeout(() => button.innerText = "Copy", 1500);
button.innerText = t("Error");
setTimeout(() => button.innerText = t("Copy"), 1500);
});
} catch (e) {
button.innerText = "Error";
setTimeout(() => button.innerText = "Copy", 1500);
button.innerText = t("Error");
setTimeout(() => button.innerText = t("Copy"), 1500);
}
};
@@ -39,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;
}
@@ -90,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}">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();
@@ -201,27 +202,27 @@ 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"
});
const formatTitle = (id) => {
if (!id) return "Text Output";
if (!id) return t("Text Output");
const shortId = id.length > 10 ? `${id.slice(0, 8)}…` : id;
return `Text Output #${shortId}`;
return `${t("Text Output #")}${shortId}`;
};
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"
});
@@ -233,7 +234,7 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
const title = document.createElement("span");
title.textContent = formatTitle(pid);
title.title = pid ? `Text Output #${pid}` : "Text Output";
title.title = pid ? `${t("Text Output #")}${pid}` : t("Text Output");
Object.assign(title.style, {
fontSize: "14px", fontWeight: "bold", color: "#f8fafc",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexShrink: "1"
@@ -241,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"
});
@@ -253,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"
@@ -262,16 +267,20 @@ 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"
});
copyBtn.innerHTML = `<i class="pi pi-copy"></i><span>Copy</span>`;
copyBtn.innerHTML = `<i class="pi pi-copy"></i><span>${t("Copy")}</span>`;
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"
@@ -285,30 +294,32 @@ 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"
});
const updateView = () => {
const wc = rawText.trim() ? rawText.trim().split(/\s+/).length : 0;
statsBadge.textContent = `${wc} words • ${rawText.length} chars`;
statsBadge.textContent = t("{wc} words • {chars} chars", { wc, chars: rawText.length });
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>Raw Text</span>`;
modeBtn.style.background = "#334155";
modeBtn.style.borderColor = "#64748b";
modeBtn.innerHTML = `<i class="pi pi-align-left"></i><span>${t("Raw Text")}</span>`;
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;
@@ -322,8 +333,8 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
copyBtn.onclick = () => {
navigator.clipboard.writeText(rawText);
copyBtn.innerHTML = `<i class="pi pi-check" style="color:#10b981;"></i><span style="color:#10b981;">Copied!</span>`;
setTimeout(() => { copyBtn.innerHTML = `<i class="pi pi-copy"></i><span>Copy</span>`; }, 1800);
copyBtn.innerHTML = `<i class="pi pi-check" style="color:#10b981;"></i><span style="color:#10b981;">${t("Copied!")}</span>`;
setTimeout(() => { copyBtn.innerHTML = `<i class="pi pi-copy"></i><span>${t("Copy")}</span>`; }, 1800);
};
downloadBtn.onclick = () => {
@@ -352,7 +363,7 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
rawText = targetData.text || "";
pid = targetData.pid || "";
title.textContent = formatTitle(pid);
title.title = pid ? `Text Output #${pid}` : "Text Output";
title.title = pid ? `${t("Text Output #")}${pid}` : t("Text Output");
updateView();
},
destroy: () => overlay.destroy()
+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;
}
+103 -58
View File
@@ -1,5 +1,6 @@
import { is3DFormat, getFilenameFromUrl } from "./utils.js";
import { SidebarOverlay } from "./overlay.js";
import { t } from "../core/i18n.js";
let cachedThreeLibs = null;
@@ -64,13 +65,17 @@ 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) || "3D Model";
titleText.textContent = getFilenameFromUrl(baseSrc) || t("3D Model");
header.appendChild(titleText);
overlay.container.appendChild(header);
@@ -84,19 +89,27 @@ 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> Loading 3D Asset...`;
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> ${t("Loading 3D Asset...")}`;
overlay.container.appendChild(loadingSpinner);
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"
});
@@ -105,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;
};
@@ -113,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 => {
@@ -130,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"
});
@@ -140,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;
@@ -221,7 +244,7 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
try {
loadingSpinner.style.display = "flex";
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner"></span> Exporting ${format}...`;
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner"></span> ${t("Exporting {format}...", { format })}`;
const base = window.__COMFY_SIDEBAR_THREE_BASE || "https://esm.sh/";
if (format === "OBJ") {
@@ -268,16 +291,16 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
const initSceneAndLoad = async (srcUrl) => {
loadingSpinner.style.display = "flex";
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> Loading 3D Asset...`;
titleText.textContent = getFilenameFromUrl(srcUrl) || "3D Model";
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> ${t("Loading 3D Asset...")}`;
titleText.textContent = getFilenameFromUrl(srcUrl) || t("3D Model");
THREE = await getThreeLibs();
if (!THREE) {
loadingSpinner.innerHTML = `
<div style="text-align: center; max-width: 340px; line-height: 1.4; padding: 6px;">
<span style="color: #f87171; font-weight: bold; font-size: 13px;">Failed to load 3D engine.</span><br>
<span style="color: #f87171; font-weight: bold; font-size: 13px;">${t("Failed to load 3D engine.")}</span><br>
<span style="font-size: 11px; color: #cbd5e1; display: inline-block; margin-top: 4px;">
Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.
${t("Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.")}
</span>
</div>
`;
@@ -320,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);
@@ -331,26 +366,26 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
bgColorInput.oninput = (e) => { scene.background.set(e.target.value); };
const upDirSelect = createSelect([
{ value: "y-up", label: "Original (Y-Up)" },
{ value: "z-up", label: "Z-Up (+90°)" },
{ value: "z-down", label: "Inverted (-90°)" }
{ value: "y-up", label: t("Original (Y-Up)") },
{ value: "z-up", label: t("Z-Up (+90°)") },
{ value: "z-down", label: t("Inverted (-90°)") }
], currentUpDirection, (val) => {
currentUpDirection = val;
applyUpDirection();
});
const matModeSelect = createSelect([
{ value: "original", label: "Original / Textured" },
{ value: "clay", label: "Clay / Studio Matte" },
{ value: "normal", label: "Normals" }
{ value: "original", label: t("Original / Textured") },
{ value: "clay", label: t("Clay / Studio Matte") },
{ value: "normal", label: t("Normals") }
], currentMaterialMode, (val) => {
currentMaterialMode = val;
applyMaterialMode();
});
const camTypeSelect = createSelect([
{ value: "perspective", label: "Perspective" },
{ value: "orthographic", label: "Orthographic" }
{ value: "perspective", label: t("Perspective") },
{ value: "orthographic", label: t("Orthographic") }
], "perspective", (val) => {
const prevPos = camera.position.clone();
const prevTarget = controls.target.clone();
@@ -396,40 +431,50 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
lightContainer.append(lightSlider, lightVal);
settingsPanel.append(
createFormGroup("Background Color", bgColorInput),
createFormGroup("Up Direction", upDirSelect),
createFormGroup("Material Mode", matModeSelect),
createFormGroup("Camera Type", camTypeSelect),
createFormGroup("FOV", fovContainer),
createFormGroup("Light Intensity", lightContainer)
createFormGroup(t("Background Color"), bgColorInput),
createFormGroup(t("Up Direction"), upDirSelect),
createFormGroup(t("Material Mode"), matModeSelect),
createFormGroup(t("Camera Type"), camTypeSelect),
createFormGroup(t("FOV"), fovContainer),
createFormGroup(t("Light Intensity"), lightContainer)
);
overlay.container.appendChild(settingsPanel);
const btnGrid = createToolbarBtn("pi pi-table", "Grid", true, (v) => { gridHelper.visible = v; });
const btnRotate = createToolbarBtn("pi pi-sync", "Auto-Rotate", false, (v) => { autoRotate = v; });
const btnWire = createToolbarBtn("pi pi-box", "Wireframe", false, (v) => {
const btnGrid = createToolbarBtn("pi pi-table", t("Grid"), true, (v) => { gridHelper.visible = v; });
const btnRotate = createToolbarBtn("pi pi-sync", t("Auto-Rotate"), false, (v) => { autoRotate = v; });
const btnWire = createToolbarBtn("pi pi-box", t("Wireframe"), false, (v) => {
showWireframe = v;
applyMaterialMode();
});
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>Reset View</span>`;
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);
};
const btnSettings = createToolbarBtn("pi pi-sliders-h", "Settings", false, (v) => {
const btnSettings = createToolbarBtn("pi pi-sliders-h", t("Settings"), false, (v) => {
settingsPanel.style.display = v ? "flex" : "none";
});
const exportSelect = createSelect([
{ value: "", label: "Export As..." },
{ value: "", label: t("Export As...") },
{ value: "OBJ", label: "OBJ (.obj)" },
{ value: "GLB", label: "GLB / GLTF" },
{ value: "STL", label: "STL (.stl)" }
@@ -439,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);
@@ -492,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();
@@ -518,7 +563,7 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
}
} catch (err) {
console.error("Comfy Sidebar: Failed to parse 3D model", err);
loadingSpinner.innerHTML = `<span style="color:#f87171;">Failed to load 3D format</span>`;
loadingSpinner.innerHTML = `<span style="color:#f87171;">${t("Failed to load 3D format")}</span>`;
return;
}
+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.1"
version = "1.8.3"
license = { file = "LICENSE" }
dependencies = [
"send2trash>=1.8.0",