Compare commits

...

15 Commits

Author SHA1 Message Date
Will Miao
130fb5d2d5 fix: batch URL download dedup by modelId+modelVersionId composite key (#936)
When batch-downloading different versions of the same model, dedup by
modelId alone discards the second URL. Use modelId:modelVersionId as
the dedup key so users can download, e.g., latest + a specific version.
2026-06-09 07:02:56 +08:00
Will Miao
23c6863a3a fix: batch URL download i18n and CSS polish (#936)
- Add common.actions.remove/change translation keys across all locales
- Remove hardcoded #e74c3c error colors, use --lora-error CSS variable
2026-06-08 21:28:24 +08:00
Will Miao
c0e2578640 feat(ui): add adaptive expand/collapse for Additional Notes section (#962) 2026-06-08 20:52:41 +08:00
Will Miao
e3c812367e fix(ui): cap lora widget height and enable wheel scroll in Node 2.0 mode (#959)
- Add 'Node 2.0: Maximum visible LoRA entries' setting (default 12)
- Apply max-height to loras container in Vue mode to prevent unbounded growth
- Add enableListWheelScroll: window capture-phase wheel hook so scroll
  inside the widget scrolls the list instead of zooming the canvas
2026-06-08 16:19:08 +08:00
Will Miao
4d239008a6 fix(update): respect hide_early_access_updates in refresh toast count
The refresh_model_updates handler was calling record.has_update() with
default hide_early_access=False, causing the toast to report early-access
updates that the Updates filter (which uses the user's hide_early_access
setting) would then hide. This resulted in misleading "Found N updates"
toasts followed by an empty Updates view.

Now the handler reads hide_early_access_updates from settings and passes
it to has_update(), matching the behavior of _serialize_record and
_annotate_update_flags.
2026-06-08 13:58:21 +08:00
Will Miao
00177a06d0 fix(ui): keep autocomplete text widget at max-height on node resize in Vue mode 2026-06-08 10:49:04 +08:00
Will Miao
568daa351e Revert "Merge pull request #959 from id-fa/fix/lora-loader-list-scroll-nodes2"
This reverts commit 01dac57c35, reversing
changes made to 62f9e3f44a.
2026-06-07 17:25:30 +08:00
Will Miao
5a4664fa12 Merge pull request #936 from 1756141021/feat/batch-url-download
feat: batch URL download for LoRA models
2026-06-06 20:22:52 +08:00
Will Miao
dd5b213adc fix(ui): make autocomplete text widget scrollable in Nodes 2.0 mode
In Vue/Node 2.0 mode, the AutocompleteTextWidget's textarea wheel events were intercepted by TransformPane @wheel.capture before reaching the @wheel handler, causing canvas zoom instead of text scrolling.

- Add lm-wheel-scrollable class in Vue mode to hook into the window capture-phase handler (enableListWheelScroll) which scrolls the textarea manually before TransformPane can react.
- Add maxHeight prop and container max-height for Lora Loader/Stacker/WanVideo nodes (modelType === 'loras'), matching canvas mode's height cap. Prompt/Text nodes remain uncapped.
2026-06-06 08:12:09 +08:00
Will Miao
d9ee9b3155 fix(utils): catch MemoryError in read_safetensors_metadata for non-safetensors files 2026-06-06 07:35:36 +08:00
pixelpaws
01dac57c35 Merge pull request #959 from id-fa/fix/lora-loader-list-scroll-nodes2
fix(ui): make Lora Loader list scrollable in Nodes 2.0 mode
2026-06-06 07:33:19 +08:00
id-fa
7f92d09239 fix(ui): make Lora Loader list scrollable in Nodes 2.0 mode
In Nodes 2.0 / Vue node mode the Lora Loader list could not be capped
and the node grew to show every row, unlike classic mode which fixes the
list area to 12 rows. The Vue layout engine measures the rendered DOM, so
CSS variables and computeLayoutSize alone were ignored.

- Physically cap the container via max-height so the rendered element is
  bounded to the 12-row height; extra rows scroll (overflow: auto).
- Report the capped height through computeSize / computeLayoutSize /
  getHeight / getMinHeight so the node background matches the list.
- Add enableListWheelScroll: a window capture-phase wheel hook that scrolls
  the hovered list instead of letting ComfyUI zoom the canvas, which fires
  on the document/canvas in capture and beat a container-level listener.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:29:01 +09:00
Will Miao
62f9e3f44a fix(scripts): use platformdirs for cross-platform settings path resolution
Both restore_suffixed_filenames.py and migrate_legacy_metadata.py
hardcoded Path.home() / '.config' / APP_NAME for finding settings.json,
which only works on Linux. On Windows this resolves to the wrong path
(~/.config/ instead of %LOCALAPPDATA%).

Replace the hand-rolled fallback with platformdirs.user_config_dir(),
which correctly resolves to the OS-appropriate config directory on all
platforms (Windows: %%LOCALAPPDATA%%, macOS: ~/Library/Application Support,
Linux: ~/.config). The portable mode check (settings.json in repo root
with use_portable_settings: true) is preserved unchanged.
2026-06-04 07:17:53 +08:00
willmiao
e55895786d docs: auto-update supporters list in README 2026-06-03 14:30:44 +00:00
hein
4e3ede23b7 feat: batch URL download for LoRA models
Add multi-URL batch download support to the download modal.
Users can paste multiple CivitAI URLs (one per line) in a textarea,
preview all parsed models in a compact list, optionally change versions
per model, select a unified download path, and batch download sequentially.

Single URL behavior is preserved unchanged.

Changes:
- Replace single-line input with textarea for multi-URL input
- Add batch preview step with compact list (thumbnail, version, size)
- Per-item version editing via existing version selector
- Batch download with WebSocket progress tracking (reuses existing infra)
- URL deduplication by model ID, preserving paste order
- Invalid URLs shown inline with remove option
- Fix: prevent click listener accumulation in showVersionStep

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:37:36 +08:00
27 changed files with 995 additions and 127 deletions

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,9 @@
"help": "Hilfe",
"add": "Hinzufügen",
"close": "Schließen",
"menu": "Menü"
"menu": "Menü",
"remove": "Entfernen",
"change": "Ändern"
},
"status": {
"loading": "Wird geladen...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "Notizen erfolgreich gespeichert",
"saveFailed": "Fehler beim Speichern der Notizen"
"saveFailed": "Fehler beim Speichern der Notizen",
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen"
},
"usageTips": {
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",

View File

@@ -16,7 +16,9 @@
"help": "Help",
"add": "Add",
"close": "Close",
"menu": "Menu"
"menu": "Menu",
"remove": "Remove",
"change": "Change"
},
"status": {
"loading": "Loading...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "Notes saved successfully",
"saveFailed": "Failed to save notes"
"saveFailed": "Failed to save notes",
"showMore": "Show more",
"showLess": "Show less"
},
"usageTips": {
"addPresetParameter": "Add preset parameter...",

View File

@@ -16,7 +16,9 @@
"help": "Ayuda",
"add": "Añadir",
"close": "Cerrar",
"menu": "Menú"
"menu": "Menú",
"remove": "Eliminar",
"change": "Cambiar"
},
"status": {
"loading": "Cargando...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "Notas guardadas exitosamente",
"saveFailed": "Error al guardar notas"
"saveFailed": "Error al guardar notas",
"showMore": "Mostrar más",
"showLess": "Mostrar menos"
},
"usageTips": {
"addPresetParameter": "Añadir parámetro preestablecido...",

View File

@@ -16,7 +16,9 @@
"help": "Aide",
"add": "Ajouter",
"close": "Fermer",
"menu": "Menu"
"menu": "Menu",
"remove": "Supprimer",
"change": "Modifier"
},
"status": {
"loading": "Chargement...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "Notes sauvegardées avec succès",
"saveFailed": "Échec de la sauvegarde des notes"
"saveFailed": "Échec de la sauvegarde des notes",
"showMore": "Afficher plus",
"showLess": "Afficher moins"
},
"usageTips": {
"addPresetParameter": "Ajouter un paramètre prédéfini...",

View File

@@ -16,7 +16,9 @@
"help": "עזרה",
"add": "הוספה",
"close": "סגור",
"menu": "תפריט"
"menu": "תפריט",
"remove": "הסר",
"change": "שנה"
},
"status": {
"loading": "טוען...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "הערות נשמרו בהצלחה",
"saveFailed": "שמירת ההערות נכשלה"
"saveFailed": "שמירת ההערות נכשלה",
"showMore": "הצג עוד",
"showLess": "הצג פחות"
},
"usageTips": {
"addPresetParameter": "הוסף פרמטר קבוע מראש...",

View File

@@ -16,7 +16,9 @@
"help": "ヘルプ",
"add": "追加",
"close": "閉じる",
"menu": "メニュー"
"menu": "メニュー",
"remove": "削除",
"change": "変更"
},
"status": {
"loading": "読み込み中...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "メモが正常に保存されました",
"saveFailed": "メモの保存に失敗しました"
"saveFailed": "メモの保存に失敗しました",
"showMore": "もっと見る",
"showLess": "折りたたむ"
},
"usageTips": {
"addPresetParameter": "プリセットパラメータを追加...",

View File

@@ -16,7 +16,9 @@
"help": "도움말",
"add": "추가",
"close": "닫기",
"menu": "메뉴"
"menu": "메뉴",
"remove": "제거",
"change": "변경"
},
"status": {
"loading": "로딩 중...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "메모가 성공적으로 저장됨",
"saveFailed": "메모 저장 실패"
"saveFailed": "메모 저장 실패",
"showMore": "더 보기",
"showLess": "접기"
},
"usageTips": {
"addPresetParameter": "프리셋 매개변수 추가...",

View File

@@ -16,7 +16,9 @@
"help": "Справка",
"add": "Добавить",
"close": "Закрыть",
"menu": "Меню"
"menu": "Меню",
"remove": "Удалить",
"change": "Изменить"
},
"status": {
"loading": "Загрузка...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "Заметки успешно сохранены",
"saveFailed": "Не удалось сохранить заметки"
"saveFailed": "Не удалось сохранить заметки",
"showMore": "Показать больше",
"showLess": "Свернуть"
},
"usageTips": {
"addPresetParameter": "Добавить предустановленный параметр...",

View File

@@ -16,7 +16,9 @@
"help": "帮助",
"add": "添加",
"close": "关闭",
"menu": "菜单"
"menu": "菜单",
"remove": "移除",
"change": "更换"
},
"status": {
"loading": "加载中...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "备注保存成功",
"saveFailed": "备注保存失败"
"saveFailed": "备注保存失败",
"showMore": "展开",
"showLess": "收起"
},
"usageTips": {
"addPresetParameter": "添加预设参数...",

View File

@@ -16,7 +16,9 @@
"help": "說明",
"add": "新增",
"close": "關閉",
"menu": "選單"
"menu": "選單",
"remove": "移除",
"change": "更換"
},
"status": {
"loading": "載入中...",
@@ -1225,7 +1227,9 @@
},
"notes": {
"saved": "備註已儲存",
"saveFailed": "儲存備註失敗"
"saveFailed": "儲存備註失敗",
"showMore": "展開",
"showLess": "收起"
},
"usageTips": {
"addPresetParameter": "新增預設參數...",

View File

@@ -2016,10 +2016,21 @@ class ModelUpdateHandler:
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
hide_early_access = False
if self._settings is not None:
try:
hide_early_access = bool(
self._settings.get("hide_early_access_updates", False)
)
except Exception:
pass
serialized_records = []
for record in records.values():
has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn():
if callable(has_update_fn) and has_update_fn(
hide_early_access=hide_early_access
):
serialized_records.append(self._serialize_record(record))
return web.json_response(

View File

@@ -81,7 +81,7 @@ def read_safetensors_metadata(file_path: str) -> dict[str, Any]:
return {}
header = json.loads(header_bytes.decode("utf-8"))
return header.get("__metadata__", {})
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error):
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error, MemoryError, Exception):
return {}

View File

@@ -34,6 +34,8 @@ import sys
from pathlib import Path
from typing import Any
from platformdirs import user_config_dir
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
@@ -53,10 +55,7 @@ def resolve_settings_path() -> Path:
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
def load_json(path: Path) -> dict[str, Any]:

View File

@@ -39,6 +39,8 @@ import sys
from pathlib import Path
from typing import Any
from platformdirs import user_config_dir
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
@@ -68,10 +70,7 @@ def resolve_settings_path() -> Path:
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
def _load_json(path: Path) -> dict[str, Any]:

View File

@@ -140,14 +140,66 @@
/* Add specific styles for notes content */
.info-item.notes .editable-field [contenteditable] {
height: 60px; /* Keep initial modal layout stable regardless of note length */
min-height: 60px; /* Increase height for multiple lines */
max-height: 420px; /* Limit maximum height */
overflow: auto; /* Enable scrolling and resize handle for long content */
resize: vertical; /* Allow manual vertical resizing */
white-space: pre-wrap; /* Preserve line breaks */
line-height: 1.5; /* Improve readability */
padding: 8px 12px; /* Slightly increase padding */
min-height: 60px;
white-space: pre-wrap;
line-height: 1.5;
padding: 8px 12px;
}
/* Notes expand/collapse — collapsed by default; only applies when JS detects long content */
.info-item.notes .editable-field {
position: relative;
max-height: none;
overflow: visible;
}
.info-item.notes .editable-field.collapsed {
max-height: 80px;
overflow: hidden;
}
/* Gradient fade overlay hint when collapsed */
.info-item.notes .editable-field.collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 28px;
background: linear-gradient(transparent, var(--bg-color));
pointer-events: none;
}
/* Notes header row — label left, toggle button right */
.notes-header {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Toggle button — icon only, inline with the label */
.notes-toggle-btn {
display: none; /* shown by JS when content exceeds threshold */
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
background: none;
color: var(--lora-accent);
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
flex-shrink: 0;
}
.notes-toggle-btn:hover {
background: rgba(66, 153, 225, 0.1);
}
.notes-toggle-btn i {
font-size: 0.85em;
}
.file-path {

View File

@@ -668,4 +668,143 @@
[data-theme="dark"] .file-tag.size {
background: oklch(0.5 0.08 160 / 0.15);
color: oklch(0.65 0.08 160);
}
/* Textarea for multi-URL input */
#modelUrl {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
resize: vertical;
line-height: 1.5;
}
/* Batch Preview List */
.batch-preview-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
}
.batch-preview-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-color);
}
.batch-preview-item:first-child {
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
}
.batch-preview-item:last-child {
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
}
.batch-preview-item:only-child {
border-radius: var(--border-radius-sm);
}
.batch-preview-thumbnail {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--lora-surface);
}
.batch-preview-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.batch-preview-icon {
width: 48px;
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--lora-error);
font-size: 1.2em;
}
.batch-preview-info {
flex: 1;
min-width: 0;
}
.batch-preview-name {
font-weight: 500;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.batch-preview-meta {
display: flex;
gap: 8px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
margin-top: 2px;
}
.batch-preview-error-text {
color: var(--lora-error);
opacity: 1;
}
.batch-preview-local-badge {
color: var(--lora-accent);
opacity: 1;
}
.batch-preview-local {
opacity: 0.6;
}
.batch-preview-change-version {
flex-shrink: 0;
font-size: 0.85em;
padding: 4px 10px;
}
.batch-preview-remove {
flex-shrink: 0;
background: none;
border: none;
color: var(--text-color);
opacity: 0.5;
cursor: pointer;
padding: 4px 8px;
font-size: 1em;
}
.batch-preview-remove:hover {
opacity: 1;
color: var(--lora-error);
}
.batch-preview-error {
background: oklch(0.5 0.15 25 / 0.05);
}
[data-theme="dark"] .batch-preview-item {
background: var(--lora-surface);
}

View File

@@ -510,7 +510,12 @@ export async function showModelModal(model, modelType) {
</div>
${typeSpecificContent}
<div class="info-item notes">
<label>${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')} <i class="fas fa-info-circle notes-hint" title="${translate('modals.model.metadata.notesHint', {}, 'Press Enter to save, Shift+Enter for new line')}"></i></label>
<div class="notes-header">
<label>${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')} <i class="fas fa-info-circle notes-hint" title="${translate('modals.model.metadata.notesHint', {}, 'Press Enter to save, Shift+Enter for new line')}"></i></label>
<button class="notes-toggle-btn" style="display:none" title="${translate('modals.model.notes.showMore', {}, 'Show more')}">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${modelWithFullData.notes || translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}</div>
</div>
@@ -837,12 +842,70 @@ function setupEditableFields(filePath, modelType) {
});
}
// Setup adaptive expand/collapse for notes
setupNotesExpand();
// LoRA specific field setup
if (modelType === 'loras') {
setupLoraSpecificFields(filePath);
}
}
/**
* Adaptive expand/collapse for the Additional Notes section.
* Measures content height synchronously after render (before first paint,
* so no visual flash). If notes fit within ~4 lines, no toggle is shown.
* If they exceed the threshold, the field collapses with a gradient fade
* and a "Show more" button appears.
*/
function setupNotesExpand() {
const notesContainer = document.querySelector('.info-item.notes');
if (!notesContainer) return;
const notesField = notesContainer.querySelector('.editable-field');
const notesContent = notesContainer.querySelector('.notes-content');
const toggleBtn = notesContainer.querySelector('.notes-toggle-btn');
if (!notesField || !notesContent || !toggleBtn) return;
const placeholderText = translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...');
const content = notesContent.textContent || '';
const isEmpty = !content.trim() || content === placeholderText;
if (isEmpty) {
return;
}
// CSS default has no constraints, so scrollHeight reflects full content
const contentHeight = notesContent.scrollHeight;
const collapsedThreshold = 95; // ~4 lines
if (contentHeight <= collapsedThreshold) {
return;
}
// Long content — collapse and show toggle
notesField.classList.add('collapsed');
toggleBtn.style.display = 'inline-flex';
toggleBtn.title = translate('modals.model.notes.showMore', {}, 'Show more');
const toggleIcon = toggleBtn.querySelector('i');
toggleBtn.addEventListener('click', function onClick() {
const isCollapsed = notesField.classList.contains('collapsed');
if (isCollapsed) {
notesField.classList.remove('collapsed');
toggleBtn.title = translate('modals.model.notes.showLess', {}, 'Show less');
toggleIcon.className = 'fas fa-chevron-up';
notesField.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
notesField.classList.add('collapsed');
toggleBtn.title = translate('modals.model.notes.showMore', {}, 'Show more');
toggleIcon.className = 'fas fa-chevron-down';
}
});
}
function setupLoraSpecificFields(filePath) {
const presetSelector = document.getElementById('preset-selector');
const presetValue = document.getElementById('preset-value');

View File

@@ -22,6 +22,11 @@ export class DownloadManager {
this.apiClient = null;
this.useDefaultPath = false;
// Batch mode state
this.batchModels = [];
this.isBatchMode = false;
this.editingBatchIndex = -1;
this.loadingManager = new LoadingManager();
this.folderTreeManager = new FolderTreeManager();
this.folderClickHandler = null;
@@ -37,6 +42,8 @@ export class DownloadManager {
this.handleConfirmFileSelection = this.confirmFileSelection.bind(this);
this.handleCloseModal = this.closeModal.bind(this);
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this);
this.handleNextFromBatch = this.nextFromBatch.bind(this);
}
showDownloadModal() {
@@ -86,6 +93,10 @@ export class DownloadManager {
document.getElementById('backToVersionFromFilesBtn').addEventListener('click', this.handleBackToVersionFromFiles);
document.getElementById('confirmFileSelection').addEventListener('click', this.handleConfirmFileSelection);
// Batch preview buttons
document.getElementById('backToUrlFromBatchBtn').addEventListener('click', this.handleBackToUrlFromBatch);
document.getElementById('nextFromBatchBtn').addEventListener('click', this.handleNextFromBatch);
// Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
}
@@ -138,6 +149,9 @@ export class DownloadManager {
this.selectedFile = null;
this.selectedFolder = '';
this.batchModels = [];
this.isBatchMode = false;
this.editingBatchIndex = -1;
// Clear folder tree selection
if (this.folderTreeManager) {
@@ -157,30 +171,104 @@ export class DownloadManager {
}
async validateAndFetchVersions() {
const url = document.getElementById('modelUrl').value.trim();
const rawText = document.getElementById('modelUrl').value.trim();
const errorElement = document.getElementById('urlError');
const urls = rawText.split('\n').map(l => l.trim()).filter(Boolean);
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
this.modelId = this.extractModelId(url);
if (!this.modelId) {
throw new Error(translate('modals.download.errors.invalidUrl'));
}
await this.retrieveVersionsForModel(this.modelId, this.source);
// If we have a version ID from URL, pre-select it
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
}
this.showVersionStep();
} catch (error) {
errorElement.textContent = error.message;
} finally {
this.loadingManager.hide();
if (urls.length === 0) {
errorElement.textContent = translate('modals.download.errors.invalidUrl');
return;
}
if (urls.length === 1) {
this.isBatchMode = false;
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
this.modelId = this.extractModelId(urls[0]);
if (!this.modelId) {
throw new Error(translate('modals.download.errors.invalidUrl'));
}
await this.retrieveVersionsForModel(this.modelId, this.source);
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
}
this.showVersionStep();
} catch (error) {
errorElement.textContent = error.message;
} finally {
this.loadingManager.hide();
}
return;
}
// Multi-URL batch mode
this.isBatchMode = true;
this.batchModels = [];
errorElement.textContent = '';
const seen = new Set();
const parsed = [];
for (const url of urls) {
const result = DownloadManager.parseModelUrl(url);
if (!result.modelId) {
parsed.push({ url, error: translate('modals.download.errors.invalidUrl') });
continue;
}
// Dedup by modelId + modelVersionId combo so users can download
// different versions of the same model (e.g. latest + a specific version)
const dedupKey = result.modelVersionId
? `${result.modelId}:${result.modelVersionId}`
: result.modelId;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
parsed.push({ url, ...result, error: null });
}
if (parsed.length === 0) {
errorElement.textContent = translate('modals.download.errors.invalidUrl');
return;
}
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
let fetched = 0;
const total = parsed.filter(p => !p.error).length;
this.batchModels = new Array(parsed.length);
const fetchPromises = parsed.map(async (item, index) => {
if (item.error) {
this.batchModels[index] = { ...item, versions: [], selectedVersion: null };
return;
}
try {
const versions = await this.apiClient.fetchCivitaiVersions(item.modelId, item.source);
fetched++;
this.loadingManager.setStatus(`${fetched}/${total}`);
let selectedVersion = null;
if (versions && versions.length > 0) {
if (item.modelVersionId) {
selectedVersion = versions.find(v => v.id.toString() === item.modelVersionId) || versions[0];
} else {
selectedVersion = versions[0];
}
}
this.batchModels[index] = { ...item, versions: versions || [], selectedVersion };
} catch (err) {
this.batchModels[index] = { ...item, versions: [], selectedVersion: null, error: err.message };
}
});
await Promise.all(fetchPromises);
this.loadingManager.hide();
this.showBatchPreviewStep();
}
async fetchVersionsForCurrentModel() {
@@ -204,25 +292,30 @@ export class DownloadManager {
}
}
extractModelId(url) {
static parseModelUrl(url) {
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
if (civarchiveMatch) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
this.source = 'civarchive';
return civarchiveMatch[1];
return {
modelId: civarchiveMatch[1],
modelVersionId: versionMatch ? versionMatch[1] : null,
source: 'civarchive',
};
}
const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
if (modelId) {
this.modelVersionId = modelVersionId;
this.source = null;
return modelId;
return { modelId, modelVersionId, source: null };
}
this.modelVersionId = null;
this.source = null;
return null;
return { modelId: null, modelVersionId: null, source: null };
}
extractModelId(url) {
const result = DownloadManager.parseModelUrl(url);
this.modelVersionId = result.modelVersionId;
this.source = result.source;
return result.modelId;
}
async openForModelVersion(modelType, modelId, versionId = null) {
@@ -250,7 +343,10 @@ export class DownloadManager {
document.getElementById('versionStep').style.display = 'block';
const versionList = document.getElementById('versionList');
versionList.innerHTML = this.versions.map(version => {
const newList = versionList.cloneNode(false);
versionList.parentNode.replaceChild(newList, versionList);
newList.innerHTML = this.versions.map(version => {
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
@@ -326,7 +422,7 @@ export class DownloadManager {
}).join('');
// Add click handlers for version selection and file badge
versionList.addEventListener('click', (event) => {
newList.addEventListener('click', (event) => {
const badge = event.target.closest('.file-select-badge');
if (badge) {
event.stopPropagation();
@@ -452,18 +548,30 @@ export class DownloadManager {
}
async proceedToLocation() {
if (!this.currentVersion) {
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
// If editing a batch item's version, save and return to batch preview
if (this.isBatchMode && this.editingBatchIndex >= 0) {
if (this.currentVersion) {
this.batchModels[this.editingBatchIndex].selectedVersion = this.currentVersion;
}
this.editingBatchIndex = -1;
document.getElementById('versionStep').style.display = 'none';
this.showBatchPreviewStep();
return;
}
const existsLocally = this.currentVersion.existsLocally;
if (existsLocally) {
showToast('toast.loras.versionExists', {}, 'info');
return;
// In single-URL mode, validate version selection
if (!this.isBatchMode) {
if (!this.currentVersion) {
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
return;
}
if (this.currentVersion.existsLocally) {
showToast('toast.loras.versionExists', {}, 'info');
return;
}
}
document.getElementById('versionStep').style.display = 'none';
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
document.getElementById('locationStep').style.display = 'block';
await this.proceedToLocationContent();
}
@@ -700,14 +808,123 @@ export class DownloadManager {
this.updateTargetPath();
}
showBatchPreviewStep() {
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
document.getElementById('batchPreviewStep').style.display = 'block';
const validCount = this.batchModels.filter(m => !m.error && m.selectedVersion).length;
document.getElementById('downloadModalTitle').textContent =
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
` (${validCount})`;
const list = document.getElementById('batchPreviewList');
list.innerHTML = this.batchModels.map((item, index) => {
if (item.error) {
return `
<div class="batch-preview-item batch-preview-error" data-index="${index}">
<div class="batch-preview-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="batch-preview-info">
<div class="batch-preview-name">${item.url}</div>
<div class="batch-preview-meta batch-preview-error-text">${item.error}</div>
</div>
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
<i class="fas fa-times"></i>
</button>
</div>
`;
}
const ver = item.selectedVersion;
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
const fileSize = ver?.modelSizeKB
? (ver.modelSizeKB / 1024).toFixed(1)
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
const existsLocally = ver?.existsLocally;
return `
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
<div class="batch-preview-thumbnail">
<img src="${thumbnailUrl}" alt="">
</div>
<div class="batch-preview-info">
<div class="batch-preview-name">${ver?.name || `Model #${item.modelId}`}</div>
<div class="batch-preview-meta">
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
<span>${fileSize} MB</span>
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}</span>` : ''}
</div>
</div>
${item.versions.length > 1 ? `
<button class="batch-preview-change-version secondary-btn" data-index="${index}">
${translate('common.actions.change', {}, 'Change')}
</button>
` : ''}
</div>
`;
}).join('');
list.onclick = (e) => {
const removeBtn = e.target.closest('.batch-preview-remove');
if (removeBtn) {
const idx = parseInt(removeBtn.dataset.index);
this.batchModels.splice(idx, 1);
this.showBatchPreviewStep();
return;
}
const changeBtn = e.target.closest('.batch-preview-change-version');
if (changeBtn) {
const idx = parseInt(changeBtn.dataset.index);
this.openBatchVersionEditor(idx);
}
};
const nextBtn = document.getElementById('nextFromBatchBtn');
nextBtn.disabled = validCount === 0;
nextBtn.classList.toggle('disabled', validCount === 0);
}
openBatchVersionEditor(index) {
this.editingBatchIndex = index;
const item = this.batchModels[index];
this.versions = item.versions;
this.currentVersion = item.selectedVersion;
document.getElementById('batchPreviewStep').style.display = 'none';
this.showVersionStep();
}
backToUrlFromBatch() {
document.getElementById('batchPreviewStep').style.display = 'none';
document.getElementById('urlStep').style.display = 'block';
}
nextFromBatch() {
const validModels = this.batchModels.filter(m => !m.error && m.selectedVersion);
if (validModels.length === 0) return;
this.proceedToLocation();
}
backToUrl() {
document.getElementById('versionStep').style.display = 'none';
document.getElementById('urlStep').style.display = 'block';
if (this.isBatchMode && this.editingBatchIndex >= 0) {
this.editingBatchIndex = -1;
this.showBatchPreviewStep();
} else {
document.getElementById('urlStep').style.display = 'block';
}
}
backToVersions() {
document.getElementById('locationStep').style.display = 'none';
document.getElementById('versionStep').style.display = 'block';
if (this.isBatchMode) {
document.getElementById('batchPreviewStep').style.display = 'block';
} else {
document.getElementById('versionStep').style.display = 'block';
}
}
closeModal() {
@@ -727,34 +944,120 @@ export class DownloadManager {
return;
}
// Determine target folder and use_default_paths parameter
let targetFolder = '';
let useDefaultPaths = false;
if (this.useDefaultPath) {
useDefaultPaths = true;
targetFolder = ''; // Not needed when using default paths
} else {
targetFolder = this.folderTreeManager.getSelectedPath();
}
const fileParams = this.selectedFile ? {
type: 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp,
} : null;
if (!this.isBatchMode) {
const fileParams = this.selectedFile ? {
type: 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp,
} : null;
return this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
versionName: this.currentVersion.name,
modelRoot,
targetFolder,
useDefaultPaths,
source: this.source,
fileParams,
closeModal: true,
return this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
versionName: this.currentVersion.name,
modelRoot,
targetFolder,
useDefaultPaths,
source: this.source,
fileParams,
closeModal: true,
});
}
// Batch download mode
const downloadItems = this.batchModels.filter(m => !m.error && m.selectedVersion && !m.selectedVersion.existsLocally);
if (downloadItems.length === 0) {
showToast('toast.loras.downloadCompleted', {}, 'info');
modalManager.closeModal('downloadModal');
return;
}
modalManager.closeModal('downloadModal');
const batchDownloadId = Date.now().toString();
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
const loadingManager = state.loadingManager || this.loadingManager;
const updateProgress = loadingManager.showDownloadProgress(downloadItems.length);
let completedDownloads = 0;
let failedDownloads = 0;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'download_id') return;
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
const current = downloadItems[completedDownloads + failedDownloads];
const name = current?.selectedVersion?.name || `#${completedDownloads + failedDownloads + 1}`;
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second,
};
updateProgress(data.progress, completedDownloads, name, metrics);
}
};
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
for (let i = 0; i < downloadItems.length; i++) {
const item = downloadItems[i];
const ver = item.selectedVersion;
const name = ver?.name || `Model #${item.modelId}`;
updateProgress(0, completedDownloads, name);
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
try {
const response = await this.apiClient.downloadModel(
item.modelId,
ver.id,
modelRoot,
targetFolder,
useDefaultPaths,
batchDownloadId,
item.source
);
if (!response.success) {
failedDownloads++;
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
}
} catch (err) {
console.error(`Failed to download ${name}:`, err);
failedDownloads++;
}
}
ws.close();
loadingManager.hide();
if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
total: downloadItems.length,
}, 'warning');
}
await resetAndReload(true);
}
async downloadVersionWithDefaults(modelType, modelId, versionId, {

View File

@@ -10,7 +10,7 @@
<div class="download-step" id="urlStep">
<div class="input-group">
<label for="modelUrl" id="modelUrlLabel">{{ t('modals.download.url') }}:</label>
<input type="text" id="modelUrl" placeholder="{{ t('modals.download.placeholder') }}" />
<textarea id="modelUrl" rows="5" placeholder="{{ t('modals.download.placeholder') }}"></textarea>
<div class="error-message" id="urlError"></div>
</div>
<div class="modal-actions">
@@ -18,7 +18,18 @@
</div>
</div>
<!-- Step 2: Version Selection -->
<!-- Step 2: Batch Preview (multi-URL mode) -->
<div class="download-step" id="batchPreviewStep" style="display: none;">
<div class="batch-preview-list" id="batchPreviewList">
<!-- Batch items will be inserted here dynamically -->
</div>
<div class="modal-actions">
<button class="secondary-btn" id="backToUrlFromBatchBtn">{{ t('common.actions.back') }}</button>
<button class="primary-btn" id="nextFromBatchBtn">{{ t('common.actions.next') }}</button>
</div>
</div>
<!-- Step 3: Version Selection (single-URL or per-item editing) -->
<div class="download-step" id="versionStep" style="display: none;">
<div class="version-list" id="versionList">
<!-- Versions will be inserted here dynamically -->

View File

@@ -5,7 +5,9 @@
ref="textareaRef"
:placeholder="placeholder"
:spellcheck="spellcheck ?? false"
:class="['text-input', { 'vue-dom-mode': isVueDomMode }]"
:class="['text-input', { 'vue-dom-mode': isVueDomMode, 'lm-wheel-scrollable': isVueDomMode }]"
:style="maxHeight && isVueDomMode ? { maxHeight: maxHeight + 'px' } : undefined"
data-capture-wheel="true"
@input="onInput"
@wheel="onWheel"
/>
@@ -47,6 +49,7 @@ const props = defineProps<{
placeholder?: string
showPreview?: boolean
spellcheck?: boolean
maxHeight?: number
}>()
// Reactive ref for Vue DOM mode

View File

@@ -546,6 +546,27 @@ function normalizeAutocompleteWidgetValues(node: any, info: any) {
}
}
function applyAutocompleteTextLayoutFix(
widget: any,
container: HTMLElement | undefined,
isVueMode: boolean
): void {
if (isVueMode) {
;(widget as any).computeLayoutSize = undefined
widget.computeSize = (width?: number) =>
[width ?? 200, AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT - 4]
if (container) {
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT}px`
}
} else {
delete (widget as any).computeLayoutSize
delete (widget as any).computeSize
if (container) {
container.style.minHeight = ''
}
}
}
// Listen for Vue DOM mode setting changes and dispatch custom event
const initVueDomModeListener = () => {
if (app.ui?.settings?.addEventListener) {
@@ -554,7 +575,47 @@ const initVueDomModeListener = () => {
// before we read it (the event may fire before internal state updates)
requestAnimationFrame(() => {
const isVueDomMode = app.ui?.settings?.getSettingValue?.('Comfy.VueNodes.Enabled') ?? false
// Dispatch custom event for Vue components to listen to
if (app.graph?.nodes) {
for (const node of app.graph.nodes) {
const textWidget = node.widgets?.find(
(w: any) => w.type === 'AUTOCOMPLETE_TEXT_LORAS'
)
if (!textWidget) continue
const container = (textWidget as any).element as HTMLElement | undefined
applyAutocompleteTextLayoutFix(textWidget, container, isVueDomMode)
}
}
requestAnimationFrame(() => {
for (const nodeEl of document.querySelectorAll('[data-node-id]')) {
const grid = nodeEl.querySelector('[data-testid="node-widgets"]') as HTMLElement | null
if (!grid) continue
const nodeId = nodeEl.getAttribute('data-node-id')
const node = app.graph?.getNodeById(nodeId as any)
if (!node) continue
const rows: string[] = []
let needsFix = false
for (const w of node.widgets ?? []) {
if (w.type === 'LORA_MANAGER_AUTOCOMPLETE_METADATA') {
rows.push('min-content')
} else if (w.name === 'loras') {
rows.push('auto')
} else if (w.name === 'text' && w.type === 'AUTOCOMPLETE_TEXT_LORAS') {
rows.push(isVueDomMode ? 'min-content' : 'auto')
needsFix = true
} else {
rows.push('auto')
}
}
if (needsFix) {
grid.style.gridTemplateRows = rows.join(' ')
}
}
})
app.canvas?.setDirty(true, true)
document.dispatchEvent(new CustomEvent('lora-manager:vue-mode-change', {
detail: { isVueDomMode }
}))
@@ -655,13 +716,16 @@ function createAutocompleteTextWidgetFactory(
// Get spellcheck setting from ComfyUI settings (default: false)
const spellcheck = app.ui?.settings?.getSettingValue?.('Comfy.TextareaWidget.Spellcheck') ?? false
const maxHeight = modelType === 'loras' ? AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT : undefined
const vueApp = createApp(AutocompleteTextWidget, {
widget,
node,
modelType,
placeholder: inputOptions.placeholder || widgetName,
showPreview: true,
spellcheck
spellcheck,
maxHeight
})
vueApp.use(PrimeVue, {
@@ -673,6 +737,19 @@ function createAutocompleteTextWidgetFactory(
const appKey = instanceId
vueApps.set(appKey, vueApp)
if (maxHeight) {
container.style.maxHeight = `${maxHeight}px`
container.style.minHeight = `${maxHeight}px`
}
if (modelType === 'loras') {
applyAutocompleteTextLayoutFix(
widget,
container,
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
)
}
widget.onRemove = createVueWidgetCleanup(vueApp, () => {
vueApps.delete(appKey)
})

View File

@@ -11,10 +11,10 @@ import {
EMPTY_CONTAINER_HEIGHT
} from "./loras_widget_utils.js";
import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js";
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas, enableListWheelScroll } from "./utils.js";
import { PreviewTooltip } from "./preview_tooltip.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
import { getStrengthStepPreference } from "./settings.js";
import { getStrengthStepPreference, getLoraWidgetMaxVisibleLoras } from "./settings.js";
export function addLorasWidget(node, name, opts, callback) {
ensureLmStyles();
@@ -29,6 +29,20 @@ export function addLorasWidget(node, name, opts, callback) {
// Set initial height using CSS variables approach
const defaultHeight = 200;
// In Vue/node-2.0 mode, cap the widget height so it shows at most N entries.
// This prevents content from driving the node size beyond the cap.
// canvas/legacy mode is unaffected.
if (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode) {
const maxLoras = getLoraWidgetMaxVisibleLoras();
const gap = 5; // flex gap from .lm-loras-container CSS
const maxH = CONTAINER_PADDING + HEADER_HEIGHT + maxLoras * LORA_ENTRY_HEIGHT + maxLoras * gap;
container.style.maxHeight = `${maxH}px`;
container.style.setProperty('--comfy-widget-max-height', `${maxH}px`);
// Window capture-phase hook: scroll the widget instead of zooming the canvas
// when the wheel is over a scrollable loras list.
enableListWheelScroll(container);
}
// Check if this is a randomizer node (lock button instead of drag handle)
const isRandomizerNode = opts?.isRandomizerNode === true;

View File

@@ -39,6 +39,9 @@ const NEW_TAB_ZOOM_LEVEL = 0.8;
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
const STRENGTH_STEP_DEFAULT = 0.05;
const LORA_WIDGET_MAX_VISIBLE_SETTING_ID = "loramanager.lora_widget_max_visible_loras";
const LORA_WIDGET_MAX_VISIBLE_DEFAULT = 12;
// ============================================================================
// Helper Functions
// ============================================================================
@@ -360,6 +363,32 @@ const getStrengthStepPreference = (() => {
};
})();
const getLoraWidgetMaxVisibleLoras = (() => {
let settingsUnavailableLogged = false;
return () => {
const settingManager = app?.extensionManager?.setting;
if (!settingManager || typeof settingManager.get !== "function") {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: settings API unavailable, using default max visible loras.");
settingsUnavailableLogged = true;
}
return LORA_WIDGET_MAX_VISIBLE_DEFAULT;
}
try {
const value = settingManager.get(LORA_WIDGET_MAX_VISIBLE_SETTING_ID);
return value ?? LORA_WIDGET_MAX_VISIBLE_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read max visible loras setting, using default.", error);
settingsUnavailableLogged = true;
}
return LORA_WIDGET_MAX_VISIBLE_DEFAULT;
}
};
})();
// ============================================================================
// Register Extension with All Settings
// ============================================================================
@@ -463,6 +492,19 @@ app.registerExtension({
tooltip: "Step size for adjusting LoRA strength via arrow buttons or keyboard (default: 0.05)",
category: ["LoRA Manager", "LoRA Widget", "Strength Step"],
},
{
id: LORA_WIDGET_MAX_VISIBLE_SETTING_ID,
name: "Node 2.0: Maximum visible LoRA entries",
type: "slider",
attrs: {
min: 3,
max: 50,
step: 1,
},
defaultValue: LORA_WIDGET_MAX_VISIBLE_DEFAULT,
tooltip: "When using Node 2.0 rendering, limit the loras widget height to show at most this many entries (default: 12). Excess entries are accessible via scrollbar.",
category: ["LoRA Manager", "LoRA Widget", "Max Visible"],
},
],
async setup() {
await loadWorkflowOptions();
@@ -549,4 +591,5 @@ export {
getUsageStatisticsPreference,
getNewTabTemplatePreference,
getStrengthStepPreference,
getLoraWidgetMaxVisibleLoras,
};

View File

@@ -784,6 +784,51 @@ export function forwardWheelToCanvas(container, options = {}) {
}, { passive: false });
}
// Marks scrollable containers whose wheel scrolling must win over canvas zoom.
const LM_WHEEL_CLASS = 'lm-wheel-scrollable';
let lmWheelHookInstalled = false;
/**
* Keep vertical wheel scrolling inside a scrollable widget container, even in
* Nodes 2.0 / Vue mode where ComfyUI's wheel→zoom handler runs on the document
* in the capture phase (outer than any container-level listener).
* Installs a single capture-phase hook on `window` (the outermost dispatch
* point). When the wheel is over a marked, scrollable element, we manually
* scroll it and fully consume the event so canvas zoom never sees it.
*/
export function enableListWheelScroll(container) {
if (!container) return;
container.classList.add(LM_WHEEL_CLASS);
if (lmWheelHookInstalled) return;
lmWheelHookInstalled = true;
window.addEventListener('wheel', (event) => {
// Let pinch/zoom and horizontal gestures pass through.
if (event.ctrlKey) return;
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
const target = event.target;
if (!target || typeof target.closest !== 'function') return;
const el = target.closest(`.${LM_WHEEL_CLASS}`);
if (!el) return;
const canScrollY = el.scrollHeight > el.clientHeight + 1;
if (!canScrollY) return;
// Translate deltaMode to approximate pixels.
const unit = event.deltaMode === 1 ? 16
: event.deltaMode === 2 ? el.clientHeight
: 1;
el.scrollTop += event.deltaY * unit;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}, { capture: true, passive: false });
}
// Get connected Lora Pool node from pool_config input
export function getConnectedPoolConfigNode(node) {
if (!node?.inputs) {

View File

@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0;
}
.autocomplete-text-widget[data-v-5514bf46] {
.autocomplete-text-widget[data-v-8555b560] {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper[data-v-5514bf46] {
.input-wrapper[data-v-8555b560] {
position: relative;
flex: 1;
display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-5514bf46] {
.text-input[data-v-8555b560] {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
@@ -2150,7 +2150,7 @@ to { transform: rotate(360deg);
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-5514bf46] {
.text-input.vue-dom-mode[data-v-8555b560] {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2159,12 +2159,12 @@ to { transform: rotate(360deg);
font-size: 12px;
font-family: inherit;
}
.text-input[data-v-5514bf46]:focus {
.text-input[data-v-8555b560]:focus {
outline: none;
}
/* Clear button styles */
.clear-button[data-v-5514bf46] {
.clear-button[data-v-8555b560] {
position: absolute;
right: 6px;
bottom: 6px; /* Changed from top to bottom */
@@ -2187,31 +2187,31 @@ to { transform: rotate(360deg);
}
/* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-5514bf46] {
.input-wrapper:hover .clear-button[data-v-8555b560] {
opacity: 0.7;
pointer-events: auto;
}
.clear-button[data-v-5514bf46]:hover {
.clear-button[data-v-8555b560]:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg[data-v-5514bf46] {
.clear-button svg[data-v-8555b560] {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-5514bf46] {
.text-input.vue-dom-mode ~ .clear-button[data-v-8555b560] {
right: 8px;
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button[data-v-5514bf46]:hover {
.text-input.vue-dom-mode ~ .clear-button[data-v-8555b560]:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg[data-v-5514bf46] {
.text-input.vue-dom-mode ~ .clear-button svg[data-v-8555b560] {
width: 14px;
height: 14px;
}`));
@@ -14783,7 +14783,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
modelType: {},
placeholder: {},
showPreview: { type: Boolean },
spellcheck: { type: Boolean }
spellcheck: { type: Boolean },
maxHeight: {}
},
setup(__props) {
const props = __props;
@@ -14913,10 +14914,12 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
ref: textareaRef,
placeholder: __props.placeholder,
spellcheck: __props.spellcheck ?? false,
class: normalizeClass(["text-input", { "vue-dom-mode": isVueDomMode.value }]),
class: normalizeClass(["text-input", { "vue-dom-mode": isVueDomMode.value, "lm-wheel-scrollable": isVueDomMode.value }]),
style: normalizeStyle(__props.maxHeight && isVueDomMode.value ? { maxHeight: __props.maxHeight + "px" } : void 0),
"data-capture-wheel": "true",
onInput,
onWheel
}, null, 42, _hoisted_3),
}, null, 46, _hoisted_3),
showClearButton.value ? (openBlock(), createElementBlock("button", {
key: 0,
type: "button",
@@ -14949,7 +14952,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-5514bf46"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-8555b560"]]);
function createVueWidgetCleanup(vueApp, onCleanup) {
let didUnmount = false;
return () => {
@@ -15713,13 +15716,66 @@ function normalizeAutocompleteWidgetValues(node, info) {
info.widgets_values = repairedValues;
}
}
function applyAutocompleteTextLayoutFix(widget, container, isVueMode) {
if (isVueMode) {
widget.computeLayoutSize = void 0;
widget.computeSize = (width) => [width ?? 200, AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT - 4];
if (container) {
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT}px`;
}
} else {
delete widget.computeLayoutSize;
delete widget.computeSize;
if (container) {
container.style.minHeight = "";
}
}
}
const initVueDomModeListener = () => {
var _a2, _b;
if ((_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.addEventListener) {
app$1.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", () => {
requestAnimationFrame(() => {
var _a3, _b2, _c;
var _a3, _b2, _c, _d, _e2, _f;
const isVueDomMode = ((_c = (_b2 = (_a3 = app$1.ui) == null ? void 0 : _a3.settings) == null ? void 0 : _b2.getSettingValue) == null ? void 0 : _c.call(_b2, "Comfy.VueNodes.Enabled")) ?? false;
if ((_d = app$1.graph) == null ? void 0 : _d.nodes) {
for (const node of app$1.graph.nodes) {
const textWidget = (_e2 = node.widgets) == null ? void 0 : _e2.find(
(w2) => w2.type === "AUTOCOMPLETE_TEXT_LORAS"
);
if (!textWidget) continue;
const container = textWidget.element;
applyAutocompleteTextLayoutFix(textWidget, container, isVueDomMode);
}
}
requestAnimationFrame(() => {
var _a4;
for (const nodeEl of document.querySelectorAll("[data-node-id]")) {
const grid = nodeEl.querySelector('[data-testid="node-widgets"]');
if (!grid) continue;
const nodeId = nodeEl.getAttribute("data-node-id");
const node = (_a4 = app$1.graph) == null ? void 0 : _a4.getNodeById(nodeId);
if (!node) continue;
const rows = [];
let needsFix = false;
for (const w2 of node.widgets ?? []) {
if (w2.type === "LORA_MANAGER_AUTOCOMPLETE_METADATA") {
rows.push("min-content");
} else if (w2.name === "loras") {
rows.push("auto");
} else if (w2.name === "text" && w2.type === "AUTOCOMPLETE_TEXT_LORAS") {
rows.push(isVueDomMode ? "min-content" : "auto");
needsFix = true;
} else {
rows.push("auto");
}
}
if (needsFix) {
grid.style.gridTemplateRows = rows.join(" ");
}
}
});
(_f = app$1.canvas) == null ? void 0 : _f.setDirty(true, true);
document.dispatchEvent(new CustomEvent("lora-manager:vue-mode-change", {
detail: { isVueDomMode }
}));
@@ -15799,13 +15855,15 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
);
widget.metadataWidget = metadataWidget;
const spellcheck = ((_c = (_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false;
const maxHeight = modelType === "loras" ? AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT : void 0;
const vueApp = createApp(AutocompleteTextWidget, {
widget,
node,
modelType,
placeholder: inputOptions.placeholder || widgetName,
showPreview: true,
spellcheck
spellcheck,
maxHeight
});
vueApp.use(PrimeVue, {
unstyled: true,
@@ -15814,6 +15872,17 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
vueApp.mount(container);
const appKey = instanceId;
vueApps.set(appKey, vueApp);
if (maxHeight) {
container.style.maxHeight = `${maxHeight}px`;
container.style.minHeight = `${maxHeight}px`;
}
if (modelType === "loras") {
applyAutocompleteTextLayoutFix(
widget,
container,
typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode
);
}
widget.onRemove = createVueWidgetCleanup(vueApp, () => {
vueApps.delete(appKey);
});

File diff suppressed because one or more lines are too long