mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-09 15:30:16 -03:00
Compare commits
11 Commits
v1.1.5
...
c1bf9c6221
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1bf9c6221 | ||
|
|
75fffc1e25 | ||
|
|
f264bab65c | ||
|
|
154fcd803b | ||
|
|
4ef32d3a96 | ||
|
|
d2d109a69c | ||
|
|
3a2941d751 | ||
|
|
0ac10dfd42 | ||
|
|
9c95856b2f | ||
|
|
5ce4667d32 | ||
|
|
be53fda6df |
@@ -84,6 +84,7 @@ class Aria2Downloader:
|
|||||||
self._transfers: Dict[str, Aria2Transfer] = {}
|
self._transfers: Dict[str, Aria2Transfer] = {}
|
||||||
self._poll_interval = 0.5
|
self._poll_interval = 0.5
|
||||||
self._state_store = Aria2TransferStateStore()
|
self._state_store = Aria2TransferStateStore()
|
||||||
|
self._stderr_reader_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
@@ -115,7 +116,7 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
status = await self.get_status(download_id)
|
status = await self._get_status_with_retry(download_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
return False, "aria2 download not found"
|
return False, "aria2 download not found"
|
||||||
|
|
||||||
@@ -136,6 +137,35 @@ class Aria2Downloader:
|
|||||||
finally:
|
finally:
|
||||||
self._transfers.pop(download_id, None)
|
self._transfers.pop(download_id, None)
|
||||||
|
|
||||||
|
async def _get_status_with_retry(
|
||||||
|
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Call get_status with retry for transient RPC failures.
|
||||||
|
|
||||||
|
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
|
||||||
|
``None`` immediately when the download_id is not tracked (a missing
|
||||||
|
transfer is not a transient condition, so retrying is pointless).
|
||||||
|
|
||||||
|
A single failed RPC call should not immediately fail the download,
|
||||||
|
because aria2 may be temporarily busy (e.g. finalizing multiple
|
||||||
|
concurrent downloads) and a retry will often succeed.
|
||||||
|
"""
|
||||||
|
last_exc: Optional[Exception] = None
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return await self.get_status(download_id)
|
||||||
|
except Aria2Error as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.warning(
|
||||||
|
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
|
||||||
|
attempt + 1, max_retries, download_id, exc,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_delay)
|
||||||
|
raise Aria2Error(
|
||||||
|
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
|
||||||
async def _schedule_download(
|
async def _schedule_download(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -312,6 +342,16 @@ class Aria2Downloader:
|
|||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Shut down the RPC process and session."""
|
"""Shut down the RPC process and session."""
|
||||||
|
|
||||||
|
# Cancel the background stderr reader first so it stops reading
|
||||||
|
# from the pipe before the subprocess is terminated.
|
||||||
|
if self._stderr_reader_task is not None:
|
||||||
|
self._stderr_reader_task.cancel()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
self._stderr_reader_task = None
|
||||||
|
|
||||||
if self._rpc_session is not None:
|
if self._rpc_session is not None:
|
||||||
await self._rpc_session.close()
|
await self._rpc_session.close()
|
||||||
self._rpc_session = None
|
self._rpc_session = None
|
||||||
@@ -331,6 +371,23 @@ class Aria2Downloader:
|
|||||||
process.kill()
|
process.kill()
|
||||||
await process.wait()
|
await process.wait()
|
||||||
|
|
||||||
|
async def _drain_stderr(self) -> None:
|
||||||
|
"""Continuously drain aria2's stderr pipe so it never blocks.
|
||||||
|
|
||||||
|
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
|
||||||
|
blocks, which freezes the entire ``aria2c`` process — including its
|
||||||
|
RPC handler. This background task reads lines from stderr as they
|
||||||
|
arrive and forwards them to Python's logger.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
assert self._process is not None and self._process.stderr is not None
|
||||||
|
async for line in self._process.stderr:
|
||||||
|
text = line.decode("utf-8", errors="replace").rstrip()
|
||||||
|
if text:
|
||||||
|
logger.debug("aria2 stderr: %s", text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
||||||
try:
|
try:
|
||||||
result = callback(snapshot, snapshot)
|
result = callback(snapshot, snapshot)
|
||||||
@@ -465,6 +522,17 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
await self._wait_until_ready()
|
await self._wait_until_ready()
|
||||||
|
|
||||||
|
# Drain aria2's stderr in a background task so the pipe buffer
|
||||||
|
# never fills up. If the pipe blocks, aria2 itself freezes and
|
||||||
|
# cannot respond to RPC — this was the root cause of the
|
||||||
|
# "Failed to query aria2 download status" timeout bug.
|
||||||
|
# Must start AFTER _wait_until_ready to avoid a race where the
|
||||||
|
# drain task consumes aria2's early-exit error message before
|
||||||
|
# _wait_until_ready can read it.
|
||||||
|
self._stderr_reader_task = asyncio.create_task(
|
||||||
|
self._drain_stderr()
|
||||||
|
)
|
||||||
|
|
||||||
def _resolve_executable(self) -> str:
|
def _resolve_executable(self) -> str:
|
||||||
settings = get_settings_manager()
|
settings = get_settings_manager()
|
||||||
configured_path = (settings.get("aria2c_path") or "").strip()
|
configured_path = (settings.get("aria2c_path") or "").strip()
|
||||||
@@ -584,7 +652,9 @@ class Aria2Downloader:
|
|||||||
if self._rpc_session is None or self._rpc_session.closed:
|
if self._rpc_session is None or self._rpc_session.closed:
|
||||||
async with self._rpc_session_lock:
|
async with self._rpc_session_lock:
|
||||||
if self._rpc_session is None or self._rpc_session.closed:
|
if self._rpc_session is None or self._rpc_session.closed:
|
||||||
timeout = aiohttp.ClientTimeout(total=30)
|
timeout = aiohttp.ClientTimeout(
|
||||||
|
total=None, sock_connect=10, sock_read=60
|
||||||
|
)
|
||||||
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
||||||
return self._rpc_session
|
return self._rpc_session
|
||||||
|
|
||||||
|
|||||||
@@ -2029,7 +2029,21 @@ class DownloadManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
last_error = result
|
last_error = result
|
||||||
if os.path.exists(save_path):
|
# For aria2: if the .aria2 control file is missing, aria2 considers
|
||||||
|
# the download complete. A transient RPC failure may have made us
|
||||||
|
# think the download failed even though the file is fully on disk.
|
||||||
|
# Keep the file so a retry can find it already complete.
|
||||||
|
if (
|
||||||
|
transfer_backend == "aria2"
|
||||||
|
and os.path.exists(save_path)
|
||||||
|
and not os.path.exists(f"{save_path}.aria2")
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"aria2 download reported failure but .aria2 file is absent "
|
||||||
|
"for %s — the file is likely complete. Preserving it for retry.",
|
||||||
|
save_path,
|
||||||
|
)
|
||||||
|
elif os.path.exists(save_path):
|
||||||
try:
|
try:
|
||||||
os.remove(save_path)
|
os.remove(save_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Style for selected cards */
|
/* Style for selected cards */
|
||||||
.model-card.selected {
|
.model-card.selected {
|
||||||
box-shadow: 0 0 0 2px var(--lora-accent);
|
outline: 2px solid var(--lora-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,157 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Sort dropdown — decoupled trigger width ===========================
|
||||||
|
The native <select> sizes its trigger to the widest <option>, wasting
|
||||||
|
horizontal space when a short option is selected. This custom trigger
|
||||||
|
sizes to the currently selected text only; the dropdown menu sizes to
|
||||||
|
its content independently. The native <select> is kept in the DOM
|
||||||
|
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
|
||||||
|
and dynamically adds/removes <option>s keeps working. */
|
||||||
|
|
||||||
|
.sort-dropdown-group {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 240px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger:hover,
|
||||||
|
.sort-trigger:focus-visible {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: var(--bg-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger__caret {
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group.active .sort-trigger__caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group.active .sort-trigger {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
|
||||||
|
.sort-dropdown-group.is-disabled .sort-trigger {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown menu — sizes to its content, independent of trigger width.
|
||||||
|
Inherits base .dropdown-menu styling; capped for very long i18n text. */
|
||||||
|
.sort-dropdown-menu {
|
||||||
|
min-width: max-content;
|
||||||
|
max-width: 320px;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optgroup label rendered as a section header */
|
||||||
|
.sort-dropdown-group .sort-optgroup-label {
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-optgroup-label:first-child {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Option items */
|
||||||
|
.sort-dropdown-group .sort-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option::before {
|
||||||
|
content: '';
|
||||||
|
width: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option:hover {
|
||||||
|
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option.is-selected {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option.is-selected::before {
|
||||||
|
content: '\2713';
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visually hidden native <select> — kept in the DOM for programmatic access.
|
||||||
|
High-specificity selector overrides .control-group select { min-width: 100px }. */
|
||||||
|
.control-group .sort-select-native {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ensure hidden class works properly */
|
/* Ensure hidden class works properly */
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
|
|||||||
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||||
import { sidebarManager } from '../SidebarManager.js';
|
import { sidebarManager } from '../SidebarManager.js';
|
||||||
|
import { initSortDropdown } from './SortDropdown.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PageControls class - Unified control management for model pages
|
* PageControls class - Unified control management for model pages
|
||||||
@@ -106,6 +107,7 @@ export class PageControls {
|
|||||||
// Sort select handler
|
// Sort select handler
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy;
|
sortSelect.value = this.pageState.sortBy;
|
||||||
sortSelect.addEventListener('change', async (e) => {
|
sortSelect.addEventListener('change', async (e) => {
|
||||||
this.pageState.sortBy = e.target.value;
|
this.pageState.sortBy = e.target.value;
|
||||||
|
|||||||
290
static/js/components/controls/SortDropdown.js
Normal file
290
static/js/components/controls/SortDropdown.js
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
// SortDropdown.js — Decoupled sort trigger.
|
||||||
|
//
|
||||||
|
// The native <select> sizes its trigger to the widest <option>, so long
|
||||||
|
// options (e.g. "Fewest versions first") or long i18n translations force the
|
||||||
|
// control to be far wider than the selected text needs. This module wraps the
|
||||||
|
// existing <select> with a custom trigger + menu that mirror its state, so the
|
||||||
|
// trigger sizes to the selected text while the menu sizes to its content.
|
||||||
|
//
|
||||||
|
// The native <select> stays in the DOM (visually hidden) so existing code that
|
||||||
|
// reads/writes `.value` / `.disabled` and dynamically adds/removes <option>s
|
||||||
|
// (e.g. the VLM temporary option) keeps working unchanged. The `value` and
|
||||||
|
// `disabled` setters are overridden on the instance to keep the trigger label
|
||||||
|
// and disabled styling in sync with programmatic changes.
|
||||||
|
//
|
||||||
|
// Keyboard navigation (arrows, Home/End, type-to-select) mirrors native
|
||||||
|
// <select> behavior so the control remains fully accessible.
|
||||||
|
|
||||||
|
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
|
||||||
|
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a decoupled sort dropdown around a native <select>.
|
||||||
|
* Idempotent: safe to call more than once on the same element.
|
||||||
|
* @param {HTMLSelectElement|null} select
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function initSortDropdown(select) {
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const group = select.closest(SORT_GROUP_SELECTOR);
|
||||||
|
if (!group || group.dataset.sortReady === '1') return;
|
||||||
|
|
||||||
|
const trigger = group.querySelector('.sort-trigger');
|
||||||
|
const menu = group.querySelector('.sort-dropdown-menu');
|
||||||
|
const label = group.querySelector('.sort-trigger__label');
|
||||||
|
if (!trigger || !menu || !label) return;
|
||||||
|
|
||||||
|
const getOptions = () => menu.querySelectorAll('.sort-option');
|
||||||
|
|
||||||
|
const buildItem = (opt) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'sort-option';
|
||||||
|
item.setAttribute('role', 'option');
|
||||||
|
item.tabIndex = -1;
|
||||||
|
item.dataset.value = opt.value;
|
||||||
|
item.textContent = opt.textContent;
|
||||||
|
item.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (select.disabled) return;
|
||||||
|
choose(opt.value);
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMenu = () => {
|
||||||
|
menu.innerHTML = '';
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const child of Array.from(select.children)) {
|
||||||
|
if (child.tagName === 'OPTGROUP') {
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'sort-optgroup-label';
|
||||||
|
header.textContent = child.label || '';
|
||||||
|
fragment.appendChild(header);
|
||||||
|
for (const opt of Array.from(child.children)) {
|
||||||
|
fragment.appendChild(buildItem(opt));
|
||||||
|
}
|
||||||
|
} else if (child.tagName === 'OPTION') {
|
||||||
|
fragment.appendChild(buildItem(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu.appendChild(fragment);
|
||||||
|
syncSelected();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncSelected = () => {
|
||||||
|
const value = select.value;
|
||||||
|
let labelText = '';
|
||||||
|
let matched = false;
|
||||||
|
getOptions().forEach((el) => {
|
||||||
|
const selected = el.dataset.value === value;
|
||||||
|
el.classList.toggle('is-selected', selected);
|
||||||
|
el.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||||
|
if (selected) {
|
||||||
|
labelText = el.textContent;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!matched) {
|
||||||
|
const opt = select.querySelector(`option[value="${cssEscape(value)}"]`);
|
||||||
|
labelText = opt
|
||||||
|
? opt.textContent
|
||||||
|
: (select.options[select.selectedIndex]?.textContent ?? '');
|
||||||
|
}
|
||||||
|
label.textContent = labelText;
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = (value) => {
|
||||||
|
if (select.value === value) return;
|
||||||
|
select.value = value;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
document.querySelectorAll(ACTIVE_GROUP_SELECTOR).forEach((g) => {
|
||||||
|
if (g !== group) g.classList.remove('active');
|
||||||
|
});
|
||||||
|
group.classList.add('active');
|
||||||
|
trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
// Focus the currently selected option (or the first option) so
|
||||||
|
// keyboard navigation starts from a sensible position.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const selected = menu.querySelector('.sort-option.is-selected');
|
||||||
|
(selected || getOptions()[0])?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
group.classList.remove('active');
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (group.classList.contains('active')) close();
|
||||||
|
else open();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- keyboard navigation ----
|
||||||
|
|
||||||
|
// Type-to-select buffer: accumulate characters and reset after a pause.
|
||||||
|
// Shared between trigger and menu keydown handlers.
|
||||||
|
let typeBuffer = '';
|
||||||
|
let typeTimer = null;
|
||||||
|
|
||||||
|
const focusOptionByText = (prefix) => {
|
||||||
|
const options = getOptions();
|
||||||
|
const lower = prefix.toLowerCase();
|
||||||
|
for (let i = 0; i < options.length; i++) {
|
||||||
|
if (options[i].textContent.toLowerCase().startsWith(lower)) {
|
||||||
|
options[i].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFocus = (options, direction) => {
|
||||||
|
const focused = menu.querySelector('.sort-option:focus');
|
||||||
|
let idx = focused ? Array.from(options).indexOf(focused) : -1;
|
||||||
|
idx = Math.max(0, Math.min(options.length - 1, idx + direction));
|
||||||
|
options[idx]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypeToSelect = (event) => {
|
||||||
|
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
clearTimeout(typeTimer);
|
||||||
|
typeBuffer += event.key;
|
||||||
|
focusOptionByText(typeBuffer);
|
||||||
|
typeTimer = setTimeout(() => { typeBuffer = ''; }, 800);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
trigger.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (select.disabled) return;
|
||||||
|
toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
trigger.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
} else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!select.disabled) toggle();
|
||||||
|
} else if (!group.classList.contains('active')) {
|
||||||
|
// Type-to-select on closed dropdown: open and highlight match
|
||||||
|
if (handleTypeToSelect(event)) {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.addEventListener('keydown', (event) => {
|
||||||
|
const options = getOptions();
|
||||||
|
if (options.length === 0) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
trigger.focus();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
moveFocus(options, 1);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
moveFocus(options, -1);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Home':
|
||||||
|
event.preventDefault();
|
||||||
|
options[0]?.focus();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'End':
|
||||||
|
event.preventDefault();
|
||||||
|
options[options.length - 1]?.focus();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
event.preventDefault();
|
||||||
|
if (select.disabled) return;
|
||||||
|
const focused = menu.querySelector('.sort-option:focus');
|
||||||
|
if (focused) {
|
||||||
|
choose(focused.dataset.value);
|
||||||
|
close();
|
||||||
|
trigger.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTypeToSelect(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (!group.contains(event.target)) {
|
||||||
|
close();
|
||||||
|
trigger.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- property overrides ----
|
||||||
|
|
||||||
|
// Override `value` and `disabled` on this instance so programmatic
|
||||||
|
// changes (loadSortPreference, VLM toggle, excluded-view sync, ...) keep
|
||||||
|
// the trigger label and disabled styling in sync without touching callers.
|
||||||
|
const proto = Object.getPrototypeOf(select);
|
||||||
|
const valueDescriptor =
|
||||||
|
Object.getOwnPropertyDescriptor(proto, 'value') ||
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
|
||||||
|
const disabledDescriptor =
|
||||||
|
Object.getOwnPropertyDescriptor(proto, 'disabled') ||
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'disabled');
|
||||||
|
|
||||||
|
if (valueDescriptor) {
|
||||||
|
Object.defineProperty(select, 'value', {
|
||||||
|
get() { return valueDescriptor.get.call(this); },
|
||||||
|
set(v) {
|
||||||
|
valueDescriptor.set.call(this, v);
|
||||||
|
syncSelected();
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabledDescriptor) {
|
||||||
|
Object.defineProperty(select, 'disabled', {
|
||||||
|
get() { return disabledDescriptor.get.call(this); },
|
||||||
|
set(v) {
|
||||||
|
disabledDescriptor.set.call(this, v);
|
||||||
|
group.classList.toggle('is-disabled', Boolean(v));
|
||||||
|
trigger.disabled = Boolean(v);
|
||||||
|
if (v) close();
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the menu when <option>s change (VLM adds/removes a temporary
|
||||||
|
// option at runtime).
|
||||||
|
const observer = new MutationObserver(() => buildMenu());
|
||||||
|
observer.observe(select, { childList: true });
|
||||||
|
|
||||||
|
buildMenu();
|
||||||
|
group.dataset.sortReady = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssEscape(value) {
|
||||||
|
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||||
|
return CSS.escape(value);
|
||||||
|
}
|
||||||
|
// Fallback for environments without CSS.escape
|
||||||
|
return String(value).replace(/[!"#$%&'()*+,./:;<=>?@[\]^`{|}~\\ -]/g, '\\$&');
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { DuplicatesManager } from './components/DuplicatesManager.js';
|
|||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
import { sidebarManager } from './components/SidebarManager.js';
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
|
import { initSortDropdown } from './components/controls/SortDropdown.js';
|
||||||
|
|
||||||
class RecipePageControls {
|
class RecipePageControls {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -239,6 +240,7 @@ class RecipeManager {
|
|||||||
// Sort select
|
// Sort select
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
this.pageState.sortBy = sortSelect.value;
|
||||||
|
|||||||
@@ -15,8 +15,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
|
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group sort-dropdown-group dropdown-group" data-sort-dropdown>
|
||||||
<select id="sortSelect">
|
<button type="button" class="sort-trigger" aria-haspopup="listbox" aria-expanded="false">
|
||||||
|
<span class="sort-trigger__label"></span>
|
||||||
|
<i class="fas fa-caret-down sort-trigger__caret" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu sort-dropdown-menu" role="listbox"></div>
|
||||||
|
<select id="sortSelect" class="sort-select-native" tabindex="-1" aria-hidden="true">
|
||||||
<optgroup label="{{ t('loras.controls.sort.name') }}">
|
<optgroup label="{{ t('loras.controls.sort.name') }}">
|
||||||
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
|
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
|
||||||
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
|
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
|
||||||
|
|||||||
@@ -352,3 +352,104 @@ async def test_resolve_authenticated_redirect_url_returns_location(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == "https://signed.example.com/file.safetensors"
|
assert result == "https://signed.example.com/file.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_passes_through_success(monkeypatch):
|
||||||
|
"""A successful first call returns immediately, no retries."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def fake_get_status(_id):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return {"status": "active", "completedLength": "50", "totalLength": "100"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||||
|
|
||||||
|
result = await downloader._get_status_with_retry("dummy")
|
||||||
|
assert result is not None
|
||||||
|
assert result["status"] == "active"
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_succeeds_after_transient_failure(monkeypatch):
|
||||||
|
"""A transient Aria2Error on the first call is retried and succeeds."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def fake_get_status(_id):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise Aria2Error("timeout")
|
||||||
|
return {"status": "complete", "completedLength": "100", "totalLength": "100"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||||
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||||
|
|
||||||
|
result = await downloader._get_status_with_retry("dummy")
|
||||||
|
assert result is not None
|
||||||
|
assert result["status"] == "complete"
|
||||||
|
assert call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_raises_after_all_retries_exhausted(monkeypatch):
|
||||||
|
"""All retry attempts fail → Aria2Error with a descriptive message."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
|
||||||
|
async def fake_get_status(_id):
|
||||||
|
raise Aria2Error("connection reset")
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||||
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||||
|
|
||||||
|
with pytest.raises(Aria2Error) as exc_info:
|
||||||
|
await downloader._get_status_with_retry("dummy")
|
||||||
|
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "after 4 attempts" in msg
|
||||||
|
assert "connection reset" in msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_returns_none_when_not_tracked(monkeypatch):
|
||||||
|
"""No transfer in _transfers → get_status returns None → no retry needed."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
|
||||||
|
# get_status returns None when the download_id has no transfer;
|
||||||
|
# _get_status_with_retry should propagate that without raising.
|
||||||
|
result = await downloader._get_status_with_retry("nonexistent")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wait_until_ready_includes_stderr_in_error():
|
||||||
|
"""When the subprocess exits early, its stderr output must be in Aria2Error."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
|
||||||
|
# Start a subprocess that writes a message to stderr and exits with code 28.
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable, "-c",
|
||||||
|
"import sys; print('ERROR: unknown option --fsync', file=sys.stderr); sys.exit(28)",
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Let the process exit
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
# Point the downloader at this dead process and let _wait_until_ready
|
||||||
|
# discover the exit and read stderr.
|
||||||
|
downloader._process = proc
|
||||||
|
|
||||||
|
with pytest.raises(Aria2Error) as exc_info:
|
||||||
|
await downloader._wait_until_ready()
|
||||||
|
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "code 28" in msg
|
||||||
|
assert "ERROR: unknown option --fsync" in msg
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Node Marker – right-click node marking (no dedicated node required)
|
// Node Marker – right-click node marking (no dedicated node required)
|
||||||
//
|
//
|
||||||
// Adds a "Mark as →" submenu with role options to any node's context menu.
|
// Adds a "🎯 Mark as →" submenu with role options to any node's context menu.
|
||||||
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
|
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
|
||||||
// persist with the workflow JSON.
|
// persist with the workflow JSON.
|
||||||
//
|
//
|
||||||
@@ -107,7 +107,7 @@ function buildMenuItems(node) {
|
|||||||
return [
|
return [
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
content: "Mark as",
|
content: "\uD83C\uDFAF Mark as",
|
||||||
has_submenu: true,
|
has_submenu: true,
|
||||||
submenu: {
|
submenu: {
|
||||||
options: buildSubmenuOptions(node),
|
options: buildSubmenuOptions(node),
|
||||||
|
|||||||
@@ -260,7 +260,6 @@ function createTagElement({
|
|||||||
}) {
|
}) {
|
||||||
const tagEl = document.createElement("div");
|
const tagEl = document.createElement("div");
|
||||||
tagEl.className = "comfy-tag";
|
tagEl.className = "comfy-tag";
|
||||||
tagEl.dataset.captureWheel = "true";
|
|
||||||
|
|
||||||
const baseStyles = {
|
const baseStyles = {
|
||||||
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
|
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
|
||||||
@@ -619,6 +618,36 @@ function showTagContextMenu(event, tagData, index, widget, anchorEl) {
|
|||||||
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Singleton window capture-phase wheel hook: focuses the tags container when a
|
||||||
|
// wheel event occurs inside it, so that ComfyUI's wheelCapturedByFocusedElement
|
||||||
|
// recognises this zone and does NOT forward the event to canvas (which would
|
||||||
|
// trigger zoom and stopPropagation, preventing the strength-adjustment handler).
|
||||||
|
/** @type {boolean} */
|
||||||
|
let tagWheelCaptureHookInstalled = false;
|
||||||
|
function installTagWheelCaptureHook() {
|
||||||
|
if (tagWheelCaptureHookInstalled) return;
|
||||||
|
tagWheelCaptureHookInstalled = true;
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(event) => {
|
||||||
|
// Only handle vertical mouse wheel (not pinch-zoom or horizontal swipe)
|
||||||
|
if (event.ctrlKey || event.metaKey) return;
|
||||||
|
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
|
||||||
|
|
||||||
|
const target = /** @type {Element} */ (event.target);
|
||||||
|
if (!target?.closest) return;
|
||||||
|
const targetContainer = target.closest(
|
||||||
|
'.comfy-tags-container[data-capture-wheel="true"]'
|
||||||
|
);
|
||||||
|
if (!targetContainer) return;
|
||||||
|
|
||||||
|
targetContainer.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
|
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
container.className = "comfy-tags-container";
|
container.className = "comfy-tags-container";
|
||||||
@@ -628,6 +657,29 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
forwardMiddleMouseToCanvas(container);
|
forwardMiddleMouseToCanvas(container);
|
||||||
forwardWheelToCanvas(container);
|
forwardWheelToCanvas(container);
|
||||||
|
|
||||||
|
// Vue render mode: ComfyUI's TransformPane uses a capture-phase wheel handler
|
||||||
|
// (TransformPane @wheel.capture) that checks wheelCapturedByFocusedElement.
|
||||||
|
// For that check to return true (preventing canvas zoom and allowing our
|
||||||
|
// strength-adjustment wheel handler to fire), the container needs both
|
||||||
|
// data-capture-wheel AND document.activeElement inside it.
|
||||||
|
// We make the container focusable and auto-focus it on wheel events via a
|
||||||
|
// window capture-phase hook.
|
||||||
|
container.dataset.captureWheel = "true";
|
||||||
|
container.tabIndex = -1;
|
||||||
|
|
||||||
|
// Blur on mouseleave to avoid lingering focus side effects.
|
||||||
|
container.addEventListener("mouseleave", () => {
|
||||||
|
if (document.activeElement === container) {
|
||||||
|
container.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Singleton window capture-phase wheel handler: focuses our container when
|
||||||
|
// a wheel event occurs inside it, so that wheelCapturedByFocusedElement
|
||||||
|
// recognises this zone and does NOT forward the event to canvas (which would
|
||||||
|
// trigger zoom and stopPropagation, preventing our strength handler).
|
||||||
|
installTagWheelCaptureHook();
|
||||||
|
|
||||||
Object.assign(container.style, {
|
Object.assign(container.style, {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
@@ -641,6 +693,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
alignContent: "flex-start",
|
alignContent: "flex-start",
|
||||||
|
outline: "none",
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialTagsData = opts?.defaultVal || [];
|
const initialTagsData = opts?.defaultVal || [];
|
||||||
|
|||||||
@@ -186,32 +186,59 @@ const createExtensionObject = (useActionBar) => {
|
|||||||
};
|
};
|
||||||
injectStyles();
|
injectStyles();
|
||||||
|
|
||||||
const replaceButtonIcon = () => {
|
const applyIconToButton = (button) => {
|
||||||
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
// Skip if the SVG icon is already in place
|
||||||
buttons.forEach(button => {
|
if (button.querySelector('svg')) return;
|
||||||
button.classList.add('lm-top-menu-button');
|
button.classList.add('lm-top-menu-button');
|
||||||
button.innerHTML = getLoraManagerIcon();
|
button.innerHTML = getLoraManagerIcon();
|
||||||
button.style.borderRadius = '4px';
|
button.style.borderRadius = '4px';
|
||||||
button.style.padding = '6px';
|
button.style.padding = '6px';
|
||||||
button.style.backgroundColor = 'var(--primary-bg)';
|
button.style.backgroundColor = 'var(--primary-bg)';
|
||||||
const svg = button.querySelector('svg');
|
const svg = button.querySelector('svg');
|
||||||
if (svg) {
|
if (svg) {
|
||||||
svg.style.width = '20px';
|
svg.style.width = '20px';
|
||||||
svg.style.height = '20px';
|
svg.style.height = '20px';
|
||||||
}
|
|
||||||
});
|
|
||||||
if (buttons.length === 0) {
|
|
||||||
requestAnimationFrame(replaceButtonIcon);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
requestAnimationFrame(replaceButtonIcon);
|
|
||||||
|
// Initial application — retry until the button is rendered by Vue
|
||||||
|
const pollUntilFound = () => {
|
||||||
|
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
||||||
|
if (buttons.length > 0) {
|
||||||
|
buttons.forEach(applyIconToButton);
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(pollUntilFound);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(pollUntilFound);
|
||||||
|
|
||||||
|
// MutationObserver: keep the SVG icon in place after Vue re-renders
|
||||||
|
// (e.g. when the properties panel is toggled inside a subgraph)
|
||||||
|
if (typeof MutationObserver !== 'undefined') {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
||||||
|
buttons.forEach(button => {
|
||||||
|
// Only re-apply when Vue has reset innerHTML back to <i>
|
||||||
|
if (button.querySelector('i')) {
|
||||||
|
applyIconToButton(button);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Watch the action bar and a broad ancestor so we cover re-mounts
|
||||||
|
const watchNode = document.querySelector('[data-testid="action-bar-buttons"]')
|
||||||
|
|| document.querySelector('.actionbar-container')
|
||||||
|
|| document.body;
|
||||||
|
observer.observe(watchNode, { childList: true, subtree: true });
|
||||||
|
// Store reference for potential cleanup
|
||||||
|
window.__lmIconObserver = observer;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (useActionBar) {
|
if (useActionBar) {
|
||||||
extensionObj.actionBarButtons = [
|
extensionObj.actionBarButtons = [
|
||||||
{
|
{
|
||||||
icon: "icon-[mdi--alpha-l-box] size-4",
|
icon: "icon-[lucide--layers] size-4",
|
||||||
tooltip: BUTTON_TOOLTIP,
|
tooltip: BUTTON_TOOLTIP,
|
||||||
onClick: openLoraManager
|
onClick: openLoraManager
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user