mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-09 07:20:15 -03:00
Compare commits
17 Commits
v1.1.5
...
36ef840a22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ef840a22 | ||
|
|
09c2445ac9 | ||
|
|
8a6d23f9c7 | ||
|
|
3d207b6744 | ||
|
|
b3edda62ad | ||
|
|
a429e6b1c3 | ||
|
|
c1bf9c6221 | ||
|
|
75fffc1e25 | ||
|
|
f264bab65c | ||
|
|
154fcd803b | ||
|
|
4ef32d3a96 | ||
|
|
d2d109a69c | ||
|
|
3a2941d751 | ||
|
|
0ac10dfd42 | ||
|
|
9c95856b2f | ||
|
|
5ce4667d32 | ||
|
|
be53fda6df |
@@ -123,23 +123,38 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
if model_hash_from_hashes:
|
if model_hash_from_hashes:
|
||||||
metadata["model_hash"] = model_hash_from_hashes
|
metadata["model_hash"] = model_hash_from_hashes
|
||||||
|
|
||||||
# Extract Lora hashes in alternative format
|
# Extract Lora hashes in alternative format.
|
||||||
|
# Run unconditionally (not just as fallback) so that
|
||||||
|
# non-empty hashes from Lora hashes fill in the gaps left
|
||||||
|
# by empty values in the Hashes JSON dict. Some WebUI
|
||||||
|
# builds write real hash values only to Lora hashes and
|
||||||
|
# leave the Hashes JSON values empty.
|
||||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||||
if not hashes_match and lora_hashes_match:
|
if lora_hashes_match:
|
||||||
try:
|
try:
|
||||||
lora_hashes_str = lora_hashes_match.group(1)
|
lora_hashes_str = lora_hashes_match.group(1)
|
||||||
lora_hash_entries = lora_hashes_str.split(', ')
|
lora_hash_entries = lora_hashes_str.split(', ')
|
||||||
|
|
||||||
# Initialize hashes dict if it doesn't exist
|
|
||||||
if "hashes" not in metadata:
|
|
||||||
metadata["hashes"] = {}
|
|
||||||
|
|
||||||
# Parse each lora hash entry (format: "name: hash")
|
# Parse each lora hash entry (format: "name: hash")
|
||||||
for entry in lora_hash_entries:
|
for entry in lora_hash_entries:
|
||||||
if ': ' in entry:
|
if ': ' in entry:
|
||||||
lora_name, lora_hash = entry.split(': ', 1)
|
lora_name, lora_hash = entry.split(': ', 1)
|
||||||
# Add as lora type in the same format as regular hashes
|
lora_hash = lora_hash.strip()
|
||||||
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
|
if not lora_hash:
|
||||||
|
# Skip entries without a hash value
|
||||||
|
continue
|
||||||
|
# Initialize hashes dict if it doesn't exist
|
||||||
|
if "hashes" not in metadata:
|
||||||
|
metadata["hashes"] = {}
|
||||||
|
# Add as lora type in the same format as
|
||||||
|
# regular hashes. Only override an
|
||||||
|
# existing entry if its value is empty
|
||||||
|
# (Lora hashes is the more reliable
|
||||||
|
# source when Hashes JSON has blanks).
|
||||||
|
key = f"lora:{lora_name}"
|
||||||
|
existing = metadata["hashes"].get(key, "")
|
||||||
|
if not existing:
|
||||||
|
metadata["hashes"][key] = lora_hash
|
||||||
|
|
||||||
# Remove lora hashes from params section
|
# Remove lora hashes from params section
|
||||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||||
@@ -363,6 +378,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip entries without a hash value — they can't be
|
||||||
|
# resolved via CivitAI and would only produce a
|
||||||
|
# useless "Deleted" entry in the recipe.
|
||||||
|
if not lora_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
lora_type, lora_name = hash_key.split(':', 1)
|
lora_type, lora_name = hash_key.split(':', 1)
|
||||||
|
|
||||||
# Get weight from extranet tags if available, else default to 1.0
|
# Get weight from extranet tags if available, else default to 1.0
|
||||||
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
# Try to get info from Civitai
|
# Try to get info from Civitai
|
||||||
if metadata_provider:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
if lora_hash:
|
|
||||||
# If we have hash, use it for lookup
|
|
||||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
else:
|
|
||||||
civitai_info = None
|
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -724,6 +724,16 @@ class ModelUpdateService:
|
|||||||
"Refreshing update metadata for %d %s models", total_models, model_type
|
"Refreshing update metadata for %d %s models", total_models, model_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# When filtering by folder, also collect the cross-folder version set
|
||||||
|
# so that versions already present in other folders are not reported
|
||||||
|
# as available updates. See issue #997.
|
||||||
|
all_local_versions: Optional[Dict[int, List[int]]] = None
|
||||||
|
if folder_path is not None:
|
||||||
|
all_local_versions = await self._collect_local_versions(
|
||||||
|
scanner,
|
||||||
|
target_model_ids=target_filter,
|
||||||
|
)
|
||||||
|
|
||||||
results: Dict[int, ModelUpdateRecord] = {}
|
results: Dict[int, ModelUpdateRecord] = {}
|
||||||
prefetched: Dict[int, Mapping] = {}
|
prefetched: Dict[int, Mapping] = {}
|
||||||
|
|
||||||
@@ -762,6 +772,12 @@ class ModelUpdateService:
|
|||||||
for index, (model_id, version_ids) in enumerate(
|
for index, (model_id, version_ids) in enumerate(
|
||||||
local_versions.items(), start=1
|
local_versions.items(), start=1
|
||||||
):
|
):
|
||||||
|
# Use cross-folder version IDs for is_in_library if available
|
||||||
|
all_vids: Sequence[int] = (
|
||||||
|
all_local_versions.get(model_id, [])
|
||||||
|
if all_local_versions is not None
|
||||||
|
else version_ids
|
||||||
|
)
|
||||||
record = await self._refresh_single_model(
|
record = await self._refresh_single_model(
|
||||||
model_type,
|
model_type,
|
||||||
model_id,
|
model_id,
|
||||||
@@ -769,6 +785,7 @@ class ModelUpdateService:
|
|||||||
metadata_provider,
|
metadata_provider,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
prefetched_response=prefetched.get(model_id),
|
prefetched_response=prefetched.get(model_id),
|
||||||
|
all_local_version_ids=all_vids,
|
||||||
)
|
)
|
||||||
if scanner.is_cancelled():
|
if scanner.is_cancelled():
|
||||||
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||||
@@ -964,8 +981,16 @@ class ModelUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
prefetched_response: Optional[Mapping] = None,
|
prefetched_response: Optional[Mapping] = None,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> Optional[ModelUpdateRecord]:
|
) -> Optional[ModelUpdateRecord]:
|
||||||
normalized_local = self._normalize_sequence(local_versions)
|
normalized_local = self._normalize_sequence(local_versions)
|
||||||
|
# When folder-filtering, this carries the cross-folder version set
|
||||||
|
# for is_in_library; otherwise it falls back to normalized_local.
|
||||||
|
normalized_all = (
|
||||||
|
self._normalize_sequence(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else normalized_local
|
||||||
|
)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
existing = self._get_record(model_type, model_id)
|
existing = self._get_record(model_type, model_id)
|
||||||
@@ -973,6 +998,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=now,
|
last_checked_at=now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
record = replace(record, should_ignore_model=True)
|
record = replace(record, should_ignore_model=True)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
|
|||||||
fetched_versions,
|
fetched_versions,
|
||||||
existing,
|
existing,
|
||||||
now,
|
now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=existing.last_checked_at if existing else None,
|
last_checked_at=existing.last_checked_at if existing else None,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
|
|||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
normalized_local: Sequence[int],
|
normalized_local: Sequence[int],
|
||||||
*,
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
model_type: Optional[str] = None,
|
model_type: Optional[str] = None,
|
||||||
model_id: Optional[int] = None,
|
model_id: Optional[int] = None,
|
||||||
last_checked_at: Optional[float] = None,
|
last_checked_at: Optional[float] = None,
|
||||||
version_info: Optional[Mapping] = None,
|
version_info: Optional[Mapping] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(normalized_local)
|
local_set = set(normalized_local)
|
||||||
|
# When folder-filtering, also consider versions in other folders
|
||||||
|
# as in-library so they are not reported as available updates.
|
||||||
|
effective_local_set: set[int] = (
|
||||||
|
local_set | set(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else local_set
|
||||||
|
)
|
||||||
versions: List[ModelVersionRecord] = []
|
versions: List[ModelVersionRecord] = []
|
||||||
ignore_map: Dict[int, bool] = {}
|
ignore_map: Dict[int, bool] = {}
|
||||||
if existing:
|
if existing:
|
||||||
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
|
|||||||
versions.append(
|
versions.append(
|
||||||
replace(
|
replace(
|
||||||
version,
|
version,
|
||||||
is_in_library=version.version_id in local_set,
|
is_in_library=version.version_id in effective_local_set,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif model_type is None or model_id is None:
|
elif model_type is None or model_id is None:
|
||||||
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
|
|||||||
remote_versions: Sequence[ModelVersionRecord],
|
remote_versions: Sequence[ModelVersionRecord],
|
||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
timestamp: float,
|
timestamp: float,
|
||||||
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(local_versions)
|
local_set = set(local_versions)
|
||||||
|
# When folder-filtering, also consider versions in other folders
|
||||||
|
# as in-library so they are not reported as available updates.
|
||||||
|
effective_local_set: set[int] = (
|
||||||
|
local_set | set(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else local_set
|
||||||
|
)
|
||||||
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
||||||
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
||||||
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
||||||
@@ -1406,7 +1453,7 @@ class ModelUpdateService:
|
|||||||
released_at=remote_version.released_at,
|
released_at=remote_version.released_at,
|
||||||
size_bytes=remote_version.size_bytes,
|
size_bytes=remote_version.size_bytes,
|
||||||
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
||||||
is_in_library=version_id in local_set,
|
is_in_library=version_id in effective_local_set,
|
||||||
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
||||||
sort_index=sort_map.get(version_id, index),
|
sort_index=sort_map.get(version_id, index),
|
||||||
early_access_ends_at=remote_version.early_access_ends_at,
|
early_access_ends_at=remote_version.early_access_ends_at,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -314,7 +316,12 @@ export class PageControls {
|
|||||||
* Load sort preference from storage
|
* Load sort preference from storage
|
||||||
*/
|
*/
|
||||||
loadSortPreference() {
|
loadSortPreference() {
|
||||||
const savedSort = getStorageItem(`${this.pageType}_sort`);
|
// Use separate keys for grouped vs non-grouped sort so each mode
|
||||||
|
// remembers its own preference independently
|
||||||
|
const key = state.global.settings.group_by_model
|
||||||
|
? `${this.pageType}_sort_grouped`
|
||||||
|
: `${this.pageType}_sort`;
|
||||||
|
const savedSort = getStorageItem(key);
|
||||||
if (savedSort) {
|
if (savedSort) {
|
||||||
// Handle legacy format conversion
|
// Handle legacy format conversion
|
||||||
const convertedSort = this.convertLegacySortFormat(savedSort);
|
const convertedSort = this.convertLegacySortFormat(savedSort);
|
||||||
@@ -358,7 +365,11 @@ export class PageControls {
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStorageItem(`${this.pageType}_sort`, sortValue);
|
// Separate storage for grouped vs non-grouped sort
|
||||||
|
const key = state.global.settings.group_by_model
|
||||||
|
? `${this.pageType}_sort_grouped`
|
||||||
|
: `${this.pageType}_sort`;
|
||||||
|
setStorageItem(key, sortValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -553,37 +564,28 @@ export class PageControls {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when group_by_model is toggled.
|
* Called when group_by_model is toggled.
|
||||||
* Saves current sort when entering grouped mode, restores normal sort
|
* Swaps between {pageType}_sort (non-group) and {pageType}_sort_grouped,
|
||||||
* when leaving — prevents "Most versions first" persisting after exit.
|
* so each mode remembers its own sort preference independently.
|
||||||
*/
|
*/
|
||||||
onGroupByModelToggled(isEnabled) {
|
onGroupByModelToggled(isEnabled) {
|
||||||
const normalKey = `${this.pageType}_sort_normal`;
|
|
||||||
const groupedKey = `${this.pageType}_sort_grouped`;
|
const groupedKey = `${this.pageType}_sort_grouped`;
|
||||||
|
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
// Entering group mode: save current sort for later restoration
|
// Entering group mode: restore last-used grouped sort, if any
|
||||||
setStorageItem(normalKey, this.pageState.sortBy);
|
|
||||||
// Restore previously saved grouped sort, if any
|
|
||||||
const savedGroupedSort = getStorageItem(groupedKey);
|
const savedGroupedSort = getStorageItem(groupedKey);
|
||||||
if (savedGroupedSort) {
|
if (savedGroupedSort) {
|
||||||
this.pageState.sortBy = savedGroupedSort;
|
this.pageState.sortBy = savedGroupedSort;
|
||||||
this.saveSortPreference(savedGroupedSort);
|
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.value = savedGroupedSort;
|
sortSelect.value = savedGroupedSort;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Leaving group mode: save current grouped sort aside, restore normal
|
// Leaving group mode: persist current sort for next time, restore non-group sort
|
||||||
const currentSort = this.pageState.sortBy;
|
setStorageItem(groupedKey, this.pageState.sortBy);
|
||||||
if (currentSort && currentSort.startsWith('versions_count')) {
|
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
|
||||||
setStorageItem(groupedKey, currentSort);
|
|
||||||
}
|
|
||||||
const savedNormalSort = getStorageItem(normalKey);
|
|
||||||
if (savedNormalSort) {
|
if (savedNormalSort) {
|
||||||
removeStorageItem(normalKey);
|
|
||||||
this.pageState.sortBy = savedNormalSort;
|
this.pageState.sortBy = savedNormalSort;
|
||||||
this.saveSortPreference(savedNormalSort);
|
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.value = savedNormalSort;
|
sortSelect.value = savedNormalSort;
|
||||||
|
|||||||
294
static/js/components/controls/SortDropdown.js
Normal file
294
static/js/components/controls/SortDropdown.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
// 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)) {
|
||||||
|
const wasOpen = group.classList.contains('active');
|
||||||
|
close();
|
||||||
|
// Only return focus to the trigger when the dropdown was actually
|
||||||
|
// open — avoids forcing scrollIntoView on every page click (which
|
||||||
|
// causes the scroll container to jump when clicking a model card).
|
||||||
|
if (wasOpen) 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, '\\$&');
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import { ImportManager } from './managers/ImportManager.js';
|
|||||||
import { BatchImportManager } from './managers/BatchImportManager.js';
|
import { BatchImportManager } from './managers/BatchImportManager.js';
|
||||||
import { RecipeModal } from './components/RecipeModal.js';
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
import { state, getCurrentPageState } from './state/index.js';
|
import { state, getCurrentPageState } from './state/index.js';
|
||||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
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() {
|
||||||
@@ -236,12 +237,18 @@ class RecipeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initEventListeners() {
|
initEventListeners() {
|
||||||
// Sort select
|
// Sort select — load saved preference, persist on change
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
const savedSort = getStorageItem('recipes_sort');
|
||||||
|
if (savedSort) {
|
||||||
|
this.pageState.sortBy = savedSort;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
setStorageItem('recipes_sort', sortSelect.value);
|
||||||
refreshVirtualScroll();
|
refreshVirtualScroll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -64,6 +64,74 @@ async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeyp
|
|||||||
assert result["loras"] == []
|
assert result["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypatch):
|
||||||
|
"""When Hashes JSON has empty lora hashes but Lora hashes text field has
|
||||||
|
real ones, the real hashes should be used and those LoRAs resolved
|
||||||
|
correctly; entries with empty hashes in both sources should be skipped."""
|
||||||
|
lora_version_info = {
|
||||||
|
"id": 947620,
|
||||||
|
"modelId": 98765,
|
||||||
|
"model": {"name": "cfg_scale_boost", "type": "LORA"},
|
||||||
|
"name": "v1",
|
||||||
|
"images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}],
|
||||||
|
"baseModel": "illustrious",
|
||||||
|
"downloadUrl": "https://civitai.com/api/download/models/947620",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"sizeKB": 1024,
|
||||||
|
"name": "cfg_scale_boost.safetensors",
|
||||||
|
"hashes": {"SHA256": "4605b2de07"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
assert model_hash == "4605b2de07"
|
||||||
|
return lora_version_info, None
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
raise AssertionError("get_model_version_info should not be called")
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = AutomaticMetadataParser()
|
||||||
|
|
||||||
|
metadata_text = (
|
||||||
|
"a cyberpunk portrait <lora:cfg_scale_boost:0.6>\n"
|
||||||
|
"Negative prompt: low quality\n"
|
||||||
|
"Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 123456, Size: 512x768, "
|
||||||
|
"Model hash: abc123, Model: test.safetensors, "
|
||||||
|
'Lora hashes: "cfg_scale_boost: 4605b2de07, EmptyLora: ", '
|
||||||
|
'Hashes: {"model": "abc123", "lora:cfg_scale_boost": "", "lora:EmptyLora": "", "lora:UnusedLora": ""}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(metadata_text)
|
||||||
|
|
||||||
|
# cfg_scale_boost should be resolved (hash from Lora hashes overrode empty Hashes JSON)
|
||||||
|
loras = result.get("loras", [])
|
||||||
|
assert len(loras) == 1, f"Expected 1 LoRA, got {len(loras)}"
|
||||||
|
lora = loras[0]
|
||||||
|
assert lora["name"] == "cfg_scale_boost", f"Expected cfg_scale_boost, got {lora['name']}"
|
||||||
|
assert lora["hash"] == "4605b2de07", f"Expected hash 4605b2de07, got {lora['hash']}"
|
||||||
|
assert lora.get("isDeleted") in (None, False), f"LoRA should not be deleted"
|
||||||
|
assert lora["weight"] == 0.6, f"Expected weight 0.6, got {lora['weight']}"
|
||||||
|
|
||||||
|
# EmptyLora and UnusedLora should be skipped (no hash in either source)
|
||||||
|
lora_names = [l["name"] for l in loras]
|
||||||
|
assert "EmptyLora" not in lora_names, "EmptyLora should have been skipped"
|
||||||
|
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
||||||
checkpoint_info = {
|
checkpoint_info = {
|
||||||
|
|||||||
@@ -579,3 +579,45 @@ async def test_update_in_library_versions_populates_metadata(tmp_path):
|
|||||||
assert version.preview_url == "https://example.com/preview.png"
|
assert version.preview_url == "https://example.com/preview.png"
|
||||||
assert version.is_in_library is True
|
assert version.is_in_library is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_folder_filter_considers_cross_folder_versions(tmp_path):
|
||||||
|
"""When refreshing by folder, versions in other folders must still be
|
||||||
|
considered in-library so they aren't reported as available updates."""
|
||||||
|
db_path = tmp_path / "updates.sqlite"
|
||||||
|
service = ModelUpdateService(str(db_path), ttl_seconds=0)
|
||||||
|
# Same model (modelId=1) in two folders with different versions
|
||||||
|
raw_data = [
|
||||||
|
{"civitai": {"modelId": 1, "id": 11}, "folder": "folder_a"},
|
||||||
|
{"civitai": {"modelId": 1, "id": 15}, "folder": "folder_b"},
|
||||||
|
]
|
||||||
|
scanner = DummyScanner(raw_data)
|
||||||
|
# Remote offers: 11 (in folder_a), 15 (in folder_b), 20 (truly new)
|
||||||
|
provider = DummyProvider(
|
||||||
|
{
|
||||||
|
"modelVersions": [
|
||||||
|
{"id": 11, "files": [], "images": []},
|
||||||
|
{"id": 15, "files": [], "images": []},
|
||||||
|
{"id": 20, "files": [], "images": []},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.refresh_for_model_type(
|
||||||
|
"lora", scanner, provider, folder_path="folder_a",
|
||||||
|
)
|
||||||
|
record = await service.get_record("lora", 1)
|
||||||
|
|
||||||
|
assert record is not None
|
||||||
|
|
||||||
|
# Version 15 is in folder_b — must be in_library even when filtering by folder_a
|
||||||
|
v15 = next(v for v in record.versions if v.version_id == 15)
|
||||||
|
assert v15.is_in_library is True
|
||||||
|
|
||||||
|
# Version 20 is truly new — should not be in_library
|
||||||
|
v20 = next(v for v in record.versions if v.version_id == 20)
|
||||||
|
assert v20.is_in_library is False
|
||||||
|
|
||||||
|
# has_update must be True (version 20 > max_in_library=15)
|
||||||
|
assert record.has_update() is True
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +186,9 @@ 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';
|
||||||
@@ -199,19 +199,46 @@ const createExtensionObject = (useActionBar) => {
|
|||||||
svg.style.width = '20px';
|
svg.style.width = '20px';
|
||||||
svg.style.height = '20px';
|
svg.style.height = '20px';
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
if (buttons.length === 0) {
|
|
||||||
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(replaceButtonIcon);
|
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