mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-18 11:31:26 -03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa58297973 | |||
| 5d1a22fb8f | |||
| d2f50f26f1 | |||
| 4a6042d0b4 | |||
| 846206d958 | |||
| 0daf4924f0 | |||
| d38a3d091d |
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "LoRA-Rezepte",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI"
|
||||
"sendCheckpoint": "Send to ComfyUI",
|
||||
"sendRecipe": "Send to ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "Beispiele werden geladen...",
|
||||
"versions": "Versionen werden geladen..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "[TODO: Translate] {count} hidden by SFW-only setting",
|
||||
"showExamples": "[TODO: Translate] Show examples",
|
||||
"showCount": "[TODO: Translate] Show examples ({count})",
|
||||
"hideExamples": "[TODO: Translate] Hide examples",
|
||||
"addExamples": "[TODO: Translate] Add examples",
|
||||
"previousExample": "[TODO: Translate] Previous example",
|
||||
"nextExample": "[TODO: Translate] Next example",
|
||||
"noExamples": "[TODO: Translate] No example images available",
|
||||
"addMoreExamples": "[TODO: Translate] Add more examples",
|
||||
"dragDrop": "[TODO: Translate] Drag & drop images or videos here",
|
||||
"or": "[TODO: Translate] or",
|
||||
"selectFiles": "[TODO: Translate] Select Files",
|
||||
"supportedFormats": "[TODO: Translate] Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "[TODO: Translate] Importing files...",
|
||||
"noSupportedFiles": "[TODO: Translate] No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "[TODO: Translate] All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "[TODO: Translate] Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "[TODO: Translate] You can change this in Settings",
|
||||
"nsfwMature": "[TODO: Translate] Mature Content",
|
||||
"nsfwR": "[TODO: Translate] R-rated Content",
|
||||
"nsfwX": "[TODO: Translate] X-rated Content",
|
||||
"nsfwXxx": "[TODO: Translate] XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Modellversionen",
|
||||
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
|
||||
|
||||
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "LoRA Recipes",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI"
|
||||
"sendCheckpoint": "Send to ComfyUI",
|
||||
"sendRecipe": "Send to ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "Loading examples...",
|
||||
"versions": "Loading versions..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} hidden by SFW-only setting",
|
||||
"showExamples": "Show examples",
|
||||
"showCount": "Show examples ({count})",
|
||||
"hideExamples": "Hide examples",
|
||||
"addExamples": "Add examples",
|
||||
"previousExample": "Previous example",
|
||||
"nextExample": "Next example",
|
||||
"noExamples": "No example images available",
|
||||
"addMoreExamples": "Add more examples",
|
||||
"dragDrop": "Drag & drop images or videos here",
|
||||
"or": "or",
|
||||
"selectFiles": "Select Files",
|
||||
"supportedFormats": "Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "Importing files...",
|
||||
"noSupportedFiles": "No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "You can change this in Settings",
|
||||
"nsfwMature": "Mature Content",
|
||||
"nsfwR": "R-rated Content",
|
||||
"nsfwX": "X-rated Content",
|
||||
"nsfwXxx": "XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Model versions",
|
||||
"copy": "Track and manage every version of this model in one place.",
|
||||
|
||||
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "Recetas de LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Enviar a ComfyUI"
|
||||
"sendCheckpoint": "Enviar a ComfyUI",
|
||||
"sendRecipe": "Enviar a ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "Cargando ejemplos...",
|
||||
"versions": "Cargando versiones..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "[TODO: Translate] {count} hidden by SFW-only setting",
|
||||
"showExamples": "[TODO: Translate] Show examples",
|
||||
"showCount": "[TODO: Translate] Show examples ({count})",
|
||||
"hideExamples": "[TODO: Translate] Hide examples",
|
||||
"addExamples": "[TODO: Translate] Add examples",
|
||||
"previousExample": "[TODO: Translate] Previous example",
|
||||
"nextExample": "[TODO: Translate] Next example",
|
||||
"noExamples": "[TODO: Translate] No example images available",
|
||||
"addMoreExamples": "[TODO: Translate] Add more examples",
|
||||
"dragDrop": "[TODO: Translate] Drag & drop images or videos here",
|
||||
"or": "[TODO: Translate] or",
|
||||
"selectFiles": "[TODO: Translate] Select Files",
|
||||
"supportedFormats": "[TODO: Translate] Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "[TODO: Translate] Importing files...",
|
||||
"noSupportedFiles": "[TODO: Translate] No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "[TODO: Translate] All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "[TODO: Translate] Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "[TODO: Translate] You can change this in Settings",
|
||||
"nsfwMature": "[TODO: Translate] Mature Content",
|
||||
"nsfwR": "[TODO: Translate] R-rated Content",
|
||||
"nsfwX": "[TODO: Translate] X-rated Content",
|
||||
"nsfwXxx": "[TODO: Translate] XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versiones del modelo",
|
||||
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
|
||||
|
||||
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "LoRA Recipes",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Envoyer vers ComfyUI"
|
||||
"sendCheckpoint": "Envoyer vers ComfyUI",
|
||||
"sendRecipe": "Envoyer vers ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "Chargement des exemples...",
|
||||
"versions": "Chargement des versions..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "[TODO: Translate] {count} hidden by SFW-only setting",
|
||||
"showExamples": "[TODO: Translate] Show examples",
|
||||
"showCount": "[TODO: Translate] Show examples ({count})",
|
||||
"hideExamples": "[TODO: Translate] Hide examples",
|
||||
"addExamples": "[TODO: Translate] Add examples",
|
||||
"previousExample": "[TODO: Translate] Previous example",
|
||||
"nextExample": "[TODO: Translate] Next example",
|
||||
"noExamples": "[TODO: Translate] No example images available",
|
||||
"addMoreExamples": "[TODO: Translate] Add more examples",
|
||||
"dragDrop": "[TODO: Translate] Drag & drop images or videos here",
|
||||
"or": "[TODO: Translate] or",
|
||||
"selectFiles": "[TODO: Translate] Select Files",
|
||||
"supportedFormats": "[TODO: Translate] Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "[TODO: Translate] Importing files...",
|
||||
"noSupportedFiles": "[TODO: Translate] No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "[TODO: Translate] All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "[TODO: Translate] Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "[TODO: Translate] You can change this in Settings",
|
||||
"nsfwMature": "[TODO: Translate] Mature Content",
|
||||
"nsfwR": "[TODO: Translate] R-rated Content",
|
||||
"nsfwX": "[TODO: Translate] X-rated Content",
|
||||
"nsfwXxx": "[TODO: Translate] XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versions du modèle",
|
||||
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
|
||||
|
||||
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "מתכוני LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "שלח ל-ComfyUI"
|
||||
"sendCheckpoint": "שלח ל-ComfyUI",
|
||||
"sendRecipe": "שלח ל-ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "טוען דוגמאות...",
|
||||
"versions": "טוען גרסאות..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "[TODO: Translate] {count} hidden by SFW-only setting",
|
||||
"showExamples": "[TODO: Translate] Show examples",
|
||||
"showCount": "[TODO: Translate] Show examples ({count})",
|
||||
"hideExamples": "[TODO: Translate] Hide examples",
|
||||
"addExamples": "[TODO: Translate] Add examples",
|
||||
"previousExample": "[TODO: Translate] Previous example",
|
||||
"nextExample": "[TODO: Translate] Next example",
|
||||
"noExamples": "[TODO: Translate] No example images available",
|
||||
"addMoreExamples": "[TODO: Translate] Add more examples",
|
||||
"dragDrop": "[TODO: Translate] Drag & drop images or videos here",
|
||||
"or": "[TODO: Translate] or",
|
||||
"selectFiles": "[TODO: Translate] Select Files",
|
||||
"supportedFormats": "[TODO: Translate] Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "[TODO: Translate] Importing files...",
|
||||
"noSupportedFiles": "[TODO: Translate] No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "[TODO: Translate] All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "[TODO: Translate] Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "[TODO: Translate] You can change this in Settings",
|
||||
"nsfwMature": "[TODO: Translate] Mature Content",
|
||||
"nsfwR": "[TODO: Translate] R-rated Content",
|
||||
"nsfwX": "[TODO: Translate] X-rated Content",
|
||||
"nsfwXxx": "[TODO: Translate] XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "גרסאות המודל",
|
||||
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
|
||||
|
||||
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "LoRAレシピ",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUIへ送信"
|
||||
"sendCheckpoint": "ComfyUIへ送信",
|
||||
"sendRecipe": "ComfyUIへ送信"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "例を読み込み中...",
|
||||
"versions": "バージョンを読み込み中..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "[TODO: Translate] {count} hidden by SFW-only setting",
|
||||
"showExamples": "[TODO: Translate] Show examples",
|
||||
"showCount": "[TODO: Translate] Show examples ({count})",
|
||||
"hideExamples": "[TODO: Translate] Hide examples",
|
||||
"addExamples": "[TODO: Translate] Add examples",
|
||||
"previousExample": "[TODO: Translate] Previous example",
|
||||
"nextExample": "[TODO: Translate] Next example",
|
||||
"noExamples": "[TODO: Translate] No example images available",
|
||||
"addMoreExamples": "[TODO: Translate] Add more examples",
|
||||
"dragDrop": "[TODO: Translate] Drag & drop images or videos here",
|
||||
"or": "[TODO: Translate] or",
|
||||
"selectFiles": "[TODO: Translate] Select Files",
|
||||
"supportedFormats": "[TODO: Translate] Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "[TODO: Translate] Importing files...",
|
||||
"noSupportedFiles": "[TODO: Translate] No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "[TODO: Translate] All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "[TODO: Translate] Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "[TODO: Translate] You can change this in Settings",
|
||||
"nsfwMature": "[TODO: Translate] Mature Content",
|
||||
"nsfwR": "[TODO: Translate] R-rated Content",
|
||||
"nsfwX": "[TODO: Translate] X-rated Content",
|
||||
"nsfwXxx": "[TODO: Translate] XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "モデルバージョン",
|
||||
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
|
||||
|
||||
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "LoRA 레시피",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUI로 보내기"
|
||||
"sendCheckpoint": "ComfyUI로 보내기",
|
||||
"sendRecipe": "ComfyUI로 보내기"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "예시 로딩 중...",
|
||||
"versions": "버전 로딩 중..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "[TODO: Translate] {count} hidden by SFW-only setting",
|
||||
"showExamples": "[TODO: Translate] Show examples",
|
||||
"showCount": "[TODO: Translate] Show examples ({count})",
|
||||
"hideExamples": "[TODO: Translate] Hide examples",
|
||||
"addExamples": "[TODO: Translate] Add examples",
|
||||
"previousExample": "[TODO: Translate] Previous example",
|
||||
"nextExample": "[TODO: Translate] Next example",
|
||||
"noExamples": "[TODO: Translate] No example images available",
|
||||
"addMoreExamples": "[TODO: Translate] Add more examples",
|
||||
"dragDrop": "[TODO: Translate] Drag & drop images or videos here",
|
||||
"or": "[TODO: Translate] or",
|
||||
"selectFiles": "[TODO: Translate] Select Files",
|
||||
"supportedFormats": "[TODO: Translate] Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "[TODO: Translate] Importing files...",
|
||||
"noSupportedFiles": "[TODO: Translate] No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "[TODO: Translate] All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "[TODO: Translate] Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "[TODO: Translate] You can change this in Settings",
|
||||
"nsfwMature": "[TODO: Translate] Mature Content",
|
||||
"nsfwR": "[TODO: Translate] R-rated Content",
|
||||
"nsfwX": "[TODO: Translate] X-rated Content",
|
||||
"nsfwXxx": "[TODO: Translate] XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "모델 버전",
|
||||
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
|
||||
|
||||
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "Рецепты LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Отправить в ComfyUI"
|
||||
"sendCheckpoint": "Отправить в ComfyUI",
|
||||
"sendRecipe": "Отправить в ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "Загрузка примеров...",
|
||||
"versions": "Загрузка версий..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "[TODO: Translate] {count} hidden by SFW-only setting",
|
||||
"showExamples": "[TODO: Translate] Show examples",
|
||||
"showCount": "[TODO: Translate] Show examples ({count})",
|
||||
"hideExamples": "[TODO: Translate] Hide examples",
|
||||
"addExamples": "[TODO: Translate] Add examples",
|
||||
"previousExample": "[TODO: Translate] Previous example",
|
||||
"nextExample": "[TODO: Translate] Next example",
|
||||
"noExamples": "[TODO: Translate] No example images available",
|
||||
"addMoreExamples": "[TODO: Translate] Add more examples",
|
||||
"dragDrop": "[TODO: Translate] Drag & drop images or videos here",
|
||||
"or": "[TODO: Translate] or",
|
||||
"selectFiles": "[TODO: Translate] Select Files",
|
||||
"supportedFormats": "[TODO: Translate] Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "[TODO: Translate] Importing files...",
|
||||
"noSupportedFiles": "[TODO: Translate] No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "[TODO: Translate] All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "[TODO: Translate] Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "[TODO: Translate] You can change this in Settings",
|
||||
"nsfwMature": "[TODO: Translate] Mature Content",
|
||||
"nsfwR": "[TODO: Translate] R-rated Content",
|
||||
"nsfwX": "[TODO: Translate] X-rated Content",
|
||||
"nsfwXxx": "[TODO: Translate] XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Версии модели",
|
||||
"copy": "Управляйте всеми версиями этой модели в одном месте.",
|
||||
|
||||
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "发送到 ComfyUI"
|
||||
"sendCheckpoint": "发送到 ComfyUI",
|
||||
"sendRecipe": "发送到 ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "正在加载示例...",
|
||||
"versions": "正在加载版本..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "[TODO: Translate] {count} hidden by SFW-only setting",
|
||||
"showExamples": "[TODO: Translate] Show examples",
|
||||
"showCount": "[TODO: Translate] Show examples ({count})",
|
||||
"hideExamples": "[TODO: Translate] Hide examples",
|
||||
"addExamples": "[TODO: Translate] Add examples",
|
||||
"previousExample": "[TODO: Translate] Previous example",
|
||||
"nextExample": "[TODO: Translate] Next example",
|
||||
"noExamples": "[TODO: Translate] No example images available",
|
||||
"addMoreExamples": "[TODO: Translate] Add more examples",
|
||||
"dragDrop": "[TODO: Translate] Drag & drop images or videos here",
|
||||
"or": "[TODO: Translate] or",
|
||||
"selectFiles": "[TODO: Translate] Select Files",
|
||||
"supportedFormats": "[TODO: Translate] Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "[TODO: Translate] Importing files...",
|
||||
"noSupportedFiles": "[TODO: Translate] No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "[TODO: Translate] All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "[TODO: Translate] Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "[TODO: Translate] You can change this in Settings",
|
||||
"nsfwMature": "[TODO: Translate] Mature Content",
|
||||
"nsfwR": "[TODO: Translate] R-rated Content",
|
||||
"nsfwX": "[TODO: Translate] X-rated Content",
|
||||
"nsfwXxx": "[TODO: Translate] XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在一个位置管理该模型的所有版本。",
|
||||
|
||||
+26
-1
@@ -853,7 +853,8 @@
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "傳送到 ComfyUI"
|
||||
"sendCheckpoint": "傳送到 ComfyUI",
|
||||
"sendRecipe": "傳送到 ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1532,6 +1533,30 @@
|
||||
"examples": "載入範例中...",
|
||||
"versions": "載入版本中..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "[TODO: Translate] {count} hidden by SFW-only setting",
|
||||
"showExamples": "[TODO: Translate] Show examples",
|
||||
"showCount": "[TODO: Translate] Show examples ({count})",
|
||||
"hideExamples": "[TODO: Translate] Hide examples",
|
||||
"addExamples": "[TODO: Translate] Add examples",
|
||||
"previousExample": "[TODO: Translate] Previous example",
|
||||
"nextExample": "[TODO: Translate] Next example",
|
||||
"noExamples": "[TODO: Translate] No example images available",
|
||||
"addMoreExamples": "[TODO: Translate] Add more examples",
|
||||
"dragDrop": "[TODO: Translate] Drag & drop images or videos here",
|
||||
"or": "[TODO: Translate] or",
|
||||
"selectFiles": "[TODO: Translate] Select Files",
|
||||
"supportedFormats": "[TODO: Translate] Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "[TODO: Translate] Importing files...",
|
||||
"noSupportedFiles": "[TODO: Translate] No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "[TODO: Translate] All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "[TODO: Translate] Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "[TODO: Translate] You can change this in Settings",
|
||||
"nsfwMature": "[TODO: Translate] Mature Content",
|
||||
"nsfwR": "[TODO: Translate] R-rated Content",
|
||||
"nsfwX": "[TODO: Translate] X-rated Content",
|
||||
"nsfwXxx": "[TODO: Translate] XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在同一位置追蹤並管理此模型的所有版本。",
|
||||
|
||||
@@ -3590,9 +3590,6 @@ class RecipeScanner:
|
||||
|
||||
syntax_parts: List[str] = []
|
||||
for lora in loras:
|
||||
if lora.get("isDeleted", False):
|
||||
continue
|
||||
|
||||
file_name = None
|
||||
folder = ""
|
||||
hash_value = (lora.get("hash") or "").lower()
|
||||
@@ -3627,6 +3624,8 @@ class RecipeScanner:
|
||||
break
|
||||
|
||||
if not file_name:
|
||||
if lora.get("isDeleted", False):
|
||||
continue
|
||||
file_name = lora.get("file_name", "unknown-lora")
|
||||
folder = lora.get("folder", "")
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/* Blurred backdrop for the model modal to match the recipe modal */
|
||||
#modelModal {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* Lora Modal Header */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
|
||||
@@ -4,19 +4,268 @@
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.carousel {
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
/* Gallery: collapsed indicator bar + expanded main viewer with thumbnail strip */
|
||||
|
||||
/* Collapsed indicator bar — slim, no remote media is rendered until expanded */
|
||||
.gallery-indicator-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.gallery-preview-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.gallery-preview-thumb img,
|
||||
.gallery-preview-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gallery-indicator-bar .gallery-show-btn {
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.gallery-indicator-bar .gallery-import-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Expanded gallery toolbar */
|
||||
.gallery-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Position badge floats over the main media, bottom-right */
|
||||
.gallery-position-badge {
|
||||
position: absolute;
|
||||
right: var(--space-2);
|
||||
bottom: var(--space-2);
|
||||
z-index: 6;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* While the gallery is expanded the thumbnail strip sits in the modal's
|
||||
bottom-right corner, where the back-to-top button would overlap it */
|
||||
.modal-content.showcase-expanded .back-to-top {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gallery-toolbar .gallery-import-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gallery-show-btn,
|
||||
.gallery-import-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.gallery-show-btn:hover,
|
||||
.gallery-import-btn:hover {
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.nsfw-filter-notification {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Main viewer — the container hugs the active media's aspect ratio
|
||||
(--media-aspect = width/height, set per item) so no dead space remains.
|
||||
overflow: hidden also clips the hoisted metadata panel while it is
|
||||
translated below the bottom edge, so it never extends the modal's
|
||||
scrollable height (which caused a scroll jump when it appeared) */
|
||||
.gallery-main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.main-media-container {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: min(100%, calc(min(75vh, 800px) * var(--media-aspect, 1.3333)));
|
||||
aspect-ratio: var(--media-aspect, 1.3333);
|
||||
max-height: min(75vh, 800px);
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel.collapsed {
|
||||
max-height: 0;
|
||||
.main-media-container .media-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.carousel-container {
|
||||
.main-media-container .media-wrapper img,
|
||||
.main-media-container .media-wrapper video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Nav buttons float over the media, visible on hover */
|
||||
.gallery-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 6;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-nav.prev {
|
||||
left: var(--space-2);
|
||||
}
|
||||
|
||||
.gallery-nav.next {
|
||||
right: var(--space-2);
|
||||
}
|
||||
|
||||
.gallery-main:hover .gallery-nav,
|
||||
.gallery-nav:focus-visible {
|
||||
opacity: 0.9;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gallery-nav:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Thumbnail strip */
|
||||
.gallery-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.gallery-thumb {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.gallery-thumb:hover {
|
||||
border-color: var(--text-color);
|
||||
}
|
||||
|
||||
.gallery-thumb.active {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-media.blurred {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-video-badge,
|
||||
.gallery-thumb .thumb-nsfw-badge {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 1px 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-nsfw-badge {
|
||||
top: 3px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.gallery-strip::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.gallery-strip::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Inline import zone toggled from the toolbar */
|
||||
.gallery-import-zone {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.gallery-import-zone.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gallery-import-zone .example-import-area {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.media-wrapper {
|
||||
@@ -31,16 +280,6 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.media-wrapper img,
|
||||
.media-wrapper video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.no-examples {
|
||||
text-align: center;
|
||||
padding: var(--space-3);
|
||||
@@ -48,11 +287,6 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Adjust the media wrapper for tab system */
|
||||
#showcase-tab .carousel-container {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Add styles for blurred showcase content */
|
||||
.nsfw-media-wrapper {
|
||||
position: relative;
|
||||
@@ -217,6 +451,24 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Hoisted panel: pinned to the bottom of .gallery-main at full column width */
|
||||
.gallery-main > .image-metadata-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 7;
|
||||
max-height: 60%;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.gallery-main > .image-metadata-panel.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 0.98;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Adjust to dark theme */
|
||||
[data-theme="dark"] .image-metadata-panel {
|
||||
background: var(--card-bg);
|
||||
@@ -388,31 +640,6 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Scroll Indicator */
|
||||
.scroll-indicator {
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.scroll-indicator:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.scroll-indicator span {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.lazy {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
@@ -104,15 +104,29 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Darker, blurred backdrop keeps the busy page behind the modal from bleeding through */
|
||||
#recipeModal {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
#recipeModal .modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Content-sized shell: grows with content up to the viewport limit, inner panes scroll past it */
|
||||
box-sizing: border-box; /* Include padding/border so the shell never exceeds the viewport */
|
||||
width: min(1600px, 94vw);
|
||||
max-width: min(1600px, 94vw);
|
||||
height: auto;
|
||||
max-height: calc(100vh - var(--header-height, 48px) - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#recipeModal .modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr) 420px;
|
||||
gap: var(--space-3);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -174,19 +188,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Top Section: Preview and Gen Params */
|
||||
.recipe-top-section {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
/* Left Column: Preview */
|
||||
.recipe-media-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; /* Guard against sub-pixel overflow from bordered children */
|
||||
}
|
||||
|
||||
/* Recipe Preview */
|
||||
.recipe-preview-container {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
box-sizing: border-box; /* Keep the 1px border inside the column width */
|
||||
height: auto;
|
||||
max-height: 42vh;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
@@ -196,18 +213,19 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-height: 42vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.recipe-preview-media {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-height: 42vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -340,9 +358,10 @@
|
||||
|
||||
/* Generation Parameters */
|
||||
.recipe-gen-params {
|
||||
height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gen-params-header-row {
|
||||
@@ -399,8 +418,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.param-group {
|
||||
@@ -453,8 +470,6 @@
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -526,14 +541,12 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Bottom Section: Resources */
|
||||
/* Right Column: Resources */
|
||||
.recipe-bottom-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-section-header {
|
||||
@@ -1010,18 +1023,43 @@
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.recipe-top-section {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 1500px) {
|
||||
#recipeModal .modal-body {
|
||||
grid-template-columns: 300px minmax(0, 1fr) 380px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
#recipeModal .modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recipe-preview-container {
|
||||
height: 200px;
|
||||
.recipe-media-column {
|
||||
overflow-y: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-preview-container,
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video,
|
||||
.recipe-preview-media {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.recipe-gen-params {
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-bottom-section {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.recipe-loras-list {
|
||||
max-height: 45vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,19 +1083,11 @@
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.recipe-top-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-preview-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recipe-gen-params {
|
||||
height: auto;
|
||||
max-height: 210px;
|
||||
.recipe-preview-container,
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video,
|
||||
.recipe-preview-media {
|
||||
max-height: 32vh;
|
||||
}
|
||||
|
||||
.recipe-gen-params h3 {
|
||||
@@ -1070,7 +1100,6 @@
|
||||
}
|
||||
|
||||
.param-content {
|
||||
max-height: 90px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@@ -1083,10 +1112,6 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recipe-bottom-section {
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-section-header {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ class RecipeModal {
|
||||
return;
|
||||
}
|
||||
|
||||
actionsContainer.innerHTML = '';
|
||||
actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove());
|
||||
|
||||
const sourcePath = this.currentRecipe?.source_path || '';
|
||||
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
|
||||
@@ -719,7 +719,7 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`;
|
||||
|
||||
setTimeout(() => {
|
||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||
@@ -1180,11 +1180,10 @@ class RecipeModal {
|
||||
});
|
||||
}
|
||||
|
||||
// Setup copy buttons for prompts and recipe syntax
|
||||
// Setup copy buttons for prompts and send recipe button
|
||||
setupCopyButtons() {
|
||||
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
||||
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
||||
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
|
||||
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
|
||||
|
||||
if (copyPromptBtn) {
|
||||
@@ -1207,13 +1206,6 @@ class RecipeModal {
|
||||
});
|
||||
}
|
||||
|
||||
if (copyRecipeSyntaxBtn) {
|
||||
copyRecipeSyntaxBtn.addEventListener('click', () => {
|
||||
// Use backend API to get recipe syntax
|
||||
this.fetchAndCopyRecipeSyntax();
|
||||
});
|
||||
}
|
||||
|
||||
if (sendRecipeBtn) {
|
||||
sendRecipeBtn.addEventListener('click', () => {
|
||||
// Send recipe to ComfyUI workflow
|
||||
@@ -1299,35 +1291,6 @@ class RecipeModal {
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch recipe syntax from backend and copy to clipboard
|
||||
async fetchAndCopyRecipeSyntax() {
|
||||
if (!this.recipeId) {
|
||||
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch recipe syntax from backend
|
||||
const response = await fetch(`/api/lm/recipe/${this.recipeId}/syntax`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.syntax) {
|
||||
// Use the centralized copyToClipboard utility function
|
||||
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned from server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recipe syntax:', error);
|
||||
showToast('toast.recipes.copyFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to copy text to clipboard
|
||||
copyToClipboard(text, successMessage) {
|
||||
copyToClipboard(text, successMessage);
|
||||
@@ -1632,7 +1595,7 @@ class RecipeModal {
|
||||
let headerAction = '';
|
||||
if (existsLocally && localPath) {
|
||||
headerAction = `
|
||||
<button class="resource-action primary compact checkpoint-send">
|
||||
<button class="resource-action compact checkpoint-send">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
||||
|
||||
export class SidebarManager {
|
||||
constructor() {
|
||||
@@ -252,6 +253,9 @@ export class SidebarManager {
|
||||
if (dataTransfer) {
|
||||
dataTransfer.effectAllowed = 'move';
|
||||
dataTransfer.setData('text/plain', filePaths.join(','));
|
||||
// Tag the drag as an internal card drag so preview-drop handlers on
|
||||
// other cards ignore it (no highlight, no preview replacement).
|
||||
dataTransfer.setData(MODEL_CARD_DRAG_MIME_TYPE, filePaths.join(','));
|
||||
try {
|
||||
dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
|
||||
} catch (error) {
|
||||
|
||||
@@ -134,6 +134,10 @@ export function openMediaViewer(arg1, arg2, arg3) {
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Stop propagation so bubble-phase handlers (e.g. ModalManager's
|
||||
// Escape handler) do not also close the modal underneath.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
closeMediaViewer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../../state/index.js';
|
||||
import { showModelModal } from './ModelModal.js';
|
||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
@@ -304,10 +303,20 @@ function handleCardClick(card, modelType) {
|
||||
}
|
||||
}
|
||||
|
||||
// Preview URL is not in the dataset; read it from the card's rendered media
|
||||
function getCardPreviewUrl(card) {
|
||||
const cardMedia = card.querySelector('.card-preview img, .card-preview video');
|
||||
if (!cardMedia) return '';
|
||||
return cardMedia.tagName === 'VIDEO'
|
||||
? (cardMedia.dataset.src || '')
|
||||
: (cardMedia.src || '');
|
||||
}
|
||||
|
||||
async function showModelModalFromCard(card, modelType) {
|
||||
// Create model metadata object
|
||||
const modelMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
preview_url: getCardPreviewUrl(card),
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
@@ -397,6 +406,7 @@ function showExampleAccessModal(card, modelType) {
|
||||
// Get the model data from card dataset (works for both lora and checkpoint)
|
||||
const modelMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
preview_url: getCardPreviewUrl(card),
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
@@ -421,30 +431,18 @@ function showExampleAccessModal(card, modelType) {
|
||||
// Show the model modal
|
||||
await showModelModal(modelMeta, modelType);
|
||||
|
||||
// Scroll to import area after modal is visible
|
||||
// Reveal the import entry once the modal content has rendered
|
||||
setTimeout(() => {
|
||||
const importArea = document.querySelector('.example-import-area');
|
||||
// Gallery mode: the import button is always visible — expand the zone
|
||||
const importBtn = document.querySelector('#modelModal .gallery-import-btn');
|
||||
if (importBtn) {
|
||||
importBtn.click();
|
||||
return;
|
||||
}
|
||||
// Empty state: the import area is the whole tab content — scroll to it
|
||||
const importArea = document.querySelector('#modelModal .example-import-area');
|
||||
if (importArea) {
|
||||
const showcaseTab = document.getElementById('showcase-tab');
|
||||
if (showcaseTab) {
|
||||
// First make sure showcase tab is visible
|
||||
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
|
||||
if (tabBtn && !tabBtn.classList.contains('active')) {
|
||||
tabBtn.click();
|
||||
}
|
||||
|
||||
// Then toggle showcase if collapsed
|
||||
const carousel = showcaseTab.querySelector('.carousel');
|
||||
if (carousel && carousel.classList.contains('collapsed')) {
|
||||
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
|
||||
if (scrollIndicator) {
|
||||
toggleShowcase(scrollIndicator);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally scroll to the import area
|
||||
importArea.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
@@ -457,6 +455,9 @@ function showExampleAccessModal(card, modelType) {
|
||||
export function createModelCard(model, modelType) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card'; // Reuse the same class for styling
|
||||
// Always draggable (move-to-folder in the sidebar). Accidental micro-drags
|
||||
// from click jitter are rendered harmless by the preview-drop handlers
|
||||
// below, which ignore internal card drags via MODEL_CARD_DRAG_MIME_TYPE.
|
||||
card.draggable = true;
|
||||
card.dataset.sha256 = model.sha256;
|
||||
card.dataset.filepath = model.file_path;
|
||||
@@ -649,7 +650,7 @@ export function createModelCard(model, modelType) {
|
||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||
${isVideo ?
|
||||
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
`<img draggable="false" src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
<div class="card-header">
|
||||
${shouldBlur ?
|
||||
@@ -743,6 +744,11 @@ export function createModelCard(model, modelType) {
|
||||
|
||||
// Dropping an image/video onto the card replaces the model preview via the
|
||||
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
|
||||
// Internal card drags (move-to-folder) are tagged with a custom MIME type by
|
||||
// SidebarManager and must be ignored here entirely: no highlight, no upload.
|
||||
const isInternalCardDrag = (event) =>
|
||||
Boolean(event.dataTransfer?.types?.includes(MODEL_CARD_DRAG_MIME_TYPE));
|
||||
|
||||
const preventDragDefaults = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -750,17 +756,20 @@ export function createModelCard(model, modelType) {
|
||||
|
||||
['dragenter', 'dragover'].forEach((eventName) => {
|
||||
card.addEventListener(eventName, (event) => {
|
||||
if (isInternalCardDrag(event)) return;
|
||||
preventDragDefaults(event);
|
||||
card.classList.add('drag-over');
|
||||
});
|
||||
});
|
||||
|
||||
card.addEventListener('dragleave', (event) => {
|
||||
if (isInternalCardDrag(event)) return;
|
||||
preventDragDefaults(event);
|
||||
card.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
card.addEventListener('drop', (event) => {
|
||||
if (isInternalCardDrag(event)) return;
|
||||
preventDragDefaults(event);
|
||||
card.classList.remove('drag-over');
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, se
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import {
|
||||
toggleShowcase,
|
||||
setupShowcaseScroll,
|
||||
scrollToTop,
|
||||
loadExampleImages
|
||||
} from './showcase/ShowcaseView.js';
|
||||
@@ -727,8 +725,6 @@ export async function showModelModal(model, modelType) {
|
||||
updateCardUpdateAvailability(hasUpdate);
|
||||
}
|
||||
|
||||
let showcaseCleanup;
|
||||
|
||||
const onCloseCallback = function () {
|
||||
// Clean up all handlers when modal closes for LoRA
|
||||
const modalElement = document.getElementById(modalId);
|
||||
@@ -736,10 +732,6 @@ export async function showModelModal(model, modelType) {
|
||||
modalElement.removeEventListener('click', modalElement._clickHandler);
|
||||
delete modalElement._clickHandler;
|
||||
}
|
||||
if (showcaseCleanup) {
|
||||
showcaseCleanup();
|
||||
showcaseCleanup = null;
|
||||
}
|
||||
cleanupNavigationShortcuts();
|
||||
};
|
||||
|
||||
@@ -759,6 +751,14 @@ export async function showModelModal(model, modelType) {
|
||||
if (modelType === 'embeddings' && modelWithFullData.folder) {
|
||||
activeModalElement.dataset.folder = modelWithFullData.folder;
|
||||
}
|
||||
// Show the back-to-top button once the modal content is scrolled
|
||||
const modalContent = activeModalElement.querySelector('.modal-content');
|
||||
const backToTopBtn = activeModalElement.querySelector('.back-to-top');
|
||||
if (modalContent && backToTopBtn) {
|
||||
modalContent.addEventListener('scroll', () => {
|
||||
backToTopBtn.classList.toggle('visible', modalContent.scrollTop > 300);
|
||||
});
|
||||
}
|
||||
}
|
||||
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
|
||||
const versionsTabController = initVersionsTab({
|
||||
@@ -771,7 +771,6 @@ export async function showModelModal(model, modelType) {
|
||||
onUpdateStatusChange: handleUpdateStatusChange,
|
||||
});
|
||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||
showcaseCleanup = setupShowcaseScroll(modalId);
|
||||
setupTabSwitching({
|
||||
onTabChange: async (tab) => {
|
||||
if (tab === 'versions') {
|
||||
@@ -814,7 +813,7 @@ export async function showModelModal(model, modelType) {
|
||||
const customImages = modelWithFullData.civitai?.customImages || [];
|
||||
// Combine images - regular images first, then custom images
|
||||
const allImages = [...regularImages, ...customImages];
|
||||
loadExampleImages(allImages, modelWithFullData.sha256);
|
||||
loadExampleImages(allImages, modelWithFullData.sha256, modelWithFullData.preview_url || '');
|
||||
}
|
||||
|
||||
function renderLoraSpecificContent(lora, escapedWords) {
|
||||
@@ -1316,7 +1315,6 @@ async function handleSendToWorkflow(target, modelType) {
|
||||
// Export the model modal API
|
||||
const modelModal = {
|
||||
show: showModelModal,
|
||||
toggleShowcase,
|
||||
scrollToTop
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate video wrapper HTML
|
||||
* Generate video wrapper HTML. The wrapper fills its container (the gallery's
|
||||
* main viewer) and the media is letterboxed inside via object-fit: contain.
|
||||
* @param {Object} media - Media metadata
|
||||
* @param {number} heightPercent - Height percentage for container
|
||||
* @param {boolean} shouldBlur - Whether content should be blurred
|
||||
* @param {string} nsfwText - NSFW warning text
|
||||
* @param {string} metadataPanel - Metadata panel HTML
|
||||
@@ -15,11 +15,11 @@
|
||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||
|
||||
return `
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
${shouldBlur ? `
|
||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
@@ -48,9 +48,9 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image wrapper HTML
|
||||
* Generate image wrapper HTML. The wrapper fills its container (the gallery's
|
||||
* main viewer) and the media is letterboxed inside via object-fit: contain.
|
||||
* @param {Object} media - Media metadata
|
||||
* @param {number} heightPercent - Height percentage for container
|
||||
* @param {boolean} shouldBlur - Whether content should be blurred
|
||||
* @param {string} nsfwText - NSFW warning text
|
||||
* @param {string} metadataPanel - Metadata panel HTML
|
||||
@@ -59,11 +59,11 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
|
||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||
|
||||
return `
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
${shouldBlur ? `
|
||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
|
||||
@@ -213,33 +213,31 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize metadata panel interaction handlers
|
||||
* Initialize metadata panel interaction handlers: hover over the media reveals
|
||||
* the panel and media controls (same as the legacy carousel). Panel-internal
|
||||
* buttons and wheel isolation are bound here as well.
|
||||
* @param {HTMLElement} container - Container element with media wrappers
|
||||
*/
|
||||
export function initMetadataPanelHandlers(container) {
|
||||
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
||||
|
||||
mediaWrappers.forEach(wrapper => {
|
||||
// Get the metadata panel and media element (img or video)
|
||||
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
||||
if (!metadataPanel) return;
|
||||
|
||||
const mediaControls = wrapper.querySelector('.media-controls');
|
||||
const mediaElement = wrapper.querySelector('img, video');
|
||||
|
||||
if (!mediaElement) return;
|
||||
|
||||
if (mediaElement) {
|
||||
let isOverMetadataPanel = false;
|
||||
|
||||
// Add event listeners to the wrapper for mouse tracking
|
||||
// Hovering the actual media content reveals the metadata panel and controls
|
||||
wrapper.addEventListener('mousemove', (e) => {
|
||||
// Get mouse position relative to wrapper
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Get the actual displayed dimensions of the media element
|
||||
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
||||
|
||||
// Check if mouse is over the actual media content
|
||||
const isOverMedia = (
|
||||
mouseX >= mediaRect.left &&
|
||||
mouseX <= mediaRect.right &&
|
||||
@@ -247,25 +245,22 @@ export function initMetadataPanelHandlers(container) {
|
||||
mouseY <= mediaRect.bottom
|
||||
);
|
||||
|
||||
// Show metadata panel and controls when over media content or metadata panel itself
|
||||
if (isOverMedia || isOverMetadataPanel) {
|
||||
if (metadataPanel) metadataPanel.classList.add('visible');
|
||||
metadataPanel.classList.add('visible');
|
||||
if (mediaControls) mediaControls.classList.add('visible');
|
||||
} else {
|
||||
if (metadataPanel) metadataPanel.classList.remove('visible');
|
||||
metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.addEventListener('mouseleave', () => {
|
||||
if (!isOverMetadataPanel) {
|
||||
if (metadataPanel) metadataPanel.classList.remove('visible');
|
||||
metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Add mouse enter/leave events for the metadata panel itself
|
||||
if (metadataPanel) {
|
||||
metadataPanel.addEventListener('mouseenter', () => {
|
||||
isOverMetadataPanel = true;
|
||||
metadataPanel.classList.add('visible');
|
||||
@@ -274,24 +269,10 @@ export function initMetadataPanelHandlers(container) {
|
||||
|
||||
metadataPanel.addEventListener('mouseleave', () => {
|
||||
isOverMetadataPanel = false;
|
||||
// Only hide if mouse is not over the media
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
|
||||
const isOverMedia = (
|
||||
mouseX >= mediaRect.left &&
|
||||
mouseX <= mediaRect.right &&
|
||||
mouseY >= mediaRect.top &&
|
||||
mouseY <= mediaRect.bottom
|
||||
);
|
||||
|
||||
if (!isOverMedia) {
|
||||
metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent events from bubbling
|
||||
metadataPanel.addEventListener('click', (e) => {
|
||||
@@ -396,7 +377,6 @@ export function initMetadataPanelHandlers(container) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -525,6 +505,12 @@ export function initMediaControlHandlers(container) {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Let the gallery refresh itself (removes thumbnail + selects a neighbor)
|
||||
mediaWrapper.dispatchEvent(new CustomEvent('example-media-deleted', {
|
||||
bubbles: true,
|
||||
detail: { shortId }
|
||||
}));
|
||||
|
||||
// Success: remove the media wrapper from the DOM
|
||||
mediaWrapper.style.opacity = '0';
|
||||
mediaWrapper.style.height = '0';
|
||||
@@ -649,7 +635,7 @@ export function initMediaControlHandlers(container) {
|
||||
// Initialize NSFW level buttons
|
||||
initSetNsfwHandlers(container);
|
||||
|
||||
// Media control visibility is now handled in initMetadataPanelHandlers
|
||||
// Media control visibility is handled with pure CSS (.media-wrapper:hover .media-controls)
|
||||
// Any click handlers or other functionality can still be added here
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/**
|
||||
* ShowcaseView.js
|
||||
* Shared showcase component for displaying examples in model modals (Lora/Checkpoint)
|
||||
*
|
||||
* The showcase starts collapsed as a slim indicator bar ("Show N examples"),
|
||||
* so opening the modal never triggers remote image fetches. Expanding reveals
|
||||
* a gallery: a single main viewer with prev/next controls, a horizontal
|
||||
* thumbnail strip for overview/random access, and an always-visible import
|
||||
* entry — no scrolling through a vertical stack of full-width examples.
|
||||
*/
|
||||
import { showToast } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
@@ -16,27 +22,34 @@ import {
|
||||
} from './MediaUtils.js';
|
||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
||||
import { getShowcaseUrl, getThumbnailUrl } from '../../../utils/civitaiUtils.js';
|
||||
import { openMediaViewer } from '../MediaViewer.js';
|
||||
import { escapeAttribute } from '../utils.js';
|
||||
|
||||
export const showcaseListenerMetrics = {
|
||||
wheelListeners: 0,
|
||||
mutationObservers: 0,
|
||||
backToTopHandlers: 0,
|
||||
/**
|
||||
* Current gallery state. The model modal is a singleton, so a single module-level
|
||||
* state object is sufficient; it is replaced on every render.
|
||||
*
|
||||
* The gallery starts collapsed: only the indicator bar renders, so remote
|
||||
* example images are never fetched until the user explicitly expands the
|
||||
* gallery — same lazy behavior as the legacy collapsed carousel.
|
||||
*/
|
||||
const galleryState = {
|
||||
rawImages: [],
|
||||
images: [],
|
||||
exampleFiles: [],
|
||||
activeIndex: 0,
|
||||
previewUrl: '',
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
export function resetShowcaseListenerMetrics() {
|
||||
showcaseListenerMetrics.wheelListeners = 0;
|
||||
showcaseListenerMetrics.mutationObservers = 0;
|
||||
showcaseListenerMetrics.backToTopHandlers = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load example images asynchronously
|
||||
* @param {Array} images - Array of image objects (both regular and custom)
|
||||
* @param {string} modelHash - Model hash for fetching local files
|
||||
* @param {string} previewUrl - Model preview URL shown in the collapsed state
|
||||
*/
|
||||
export async function loadExampleImages(images, modelHash) {
|
||||
export async function loadExampleImages(images, modelHash, previewUrl = '') {
|
||||
try {
|
||||
const showcaseTab = document.getElementById('showcase-tab');
|
||||
if (!showcaseTab) return;
|
||||
@@ -59,18 +72,11 @@ export async function loadExampleImages(images, modelHash) {
|
||||
}
|
||||
|
||||
// Then render with both remote images and local files
|
||||
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
|
||||
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles, previewUrl);
|
||||
|
||||
// Re-initialize the showcase event listeners
|
||||
const carousel = showcaseTab.querySelector('.carousel');
|
||||
if (carousel) {
|
||||
// Always bind scroll-indicator click events (even when collapsed)
|
||||
bindScrollIndicatorEvents(carousel);
|
||||
|
||||
// Only initialize full showcase content when expanded
|
||||
if (!carousel.classList.contains('collapsed')) {
|
||||
initShowcaseContent(carousel);
|
||||
}
|
||||
const gallery = showcaseTab.querySelector('.showcase-gallery');
|
||||
if (gallery) {
|
||||
initShowcaseContent(gallery);
|
||||
}
|
||||
|
||||
// Initialize the example import functionality
|
||||
@@ -90,16 +96,44 @@ export async function loadExampleImages(images, modelHash) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render showcase content
|
||||
* Render a small local preview thumbnail for the collapsed indicator bar
|
||||
* (local file, no remote fetch)
|
||||
* @param {string} previewUrl - Model preview URL
|
||||
* @returns {string} HTML content, empty when no preview exists
|
||||
*/
|
||||
function renderPreviewThumb(previewUrl) {
|
||||
if (!previewUrl) return '';
|
||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||
const media = isVideo
|
||||
? `<video src="${escapeAttribute(previewUrl)}" muted playsinline preload="metadata"></video>`
|
||||
: `<img src="${escapeAttribute(previewUrl)}" alt="" loading="lazy">`;
|
||||
return `<span class="gallery-preview-thumb">${media}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render showcase content: collapsed indicator bar by default, gallery
|
||||
* (main viewer + thumbnail strip + import entry) when expanded
|
||||
* @param {Array} images - Array of images/videos to show
|
||||
* @param {Array} exampleFiles - Local example files
|
||||
* @param {boolean} startExpanded - Whether to start in expanded state
|
||||
* @param {string} previewUrl - Model preview URL for the collapsed indicator bar
|
||||
* @param {boolean} expanded - Whether to render the full gallery (loads remote media)
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
|
||||
export function renderShowcaseContent(images, exampleFiles = [], previewUrl = '', expanded = false) {
|
||||
galleryState.rawImages = images || [];
|
||||
galleryState.exampleFiles = exampleFiles;
|
||||
galleryState.previewUrl = previewUrl;
|
||||
galleryState.expanded = expanded;
|
||||
|
||||
if (!images?.length) {
|
||||
// Show empty state with import interface
|
||||
return renderImportInterface(true);
|
||||
galleryState.images = [];
|
||||
galleryState.activeIndex = 0;
|
||||
// Empty state: show the import interface directly
|
||||
return `
|
||||
<div class="showcase-gallery">
|
||||
${renderImportInterface(true)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Filter images based on SFW setting
|
||||
@@ -118,39 +152,148 @@ export function renderShowcaseContent(images, exampleFiles = [], startExpanded =
|
||||
|
||||
// Show message if no images are available after filtering
|
||||
if (filteredImages.length === 0) {
|
||||
galleryState.images = [];
|
||||
galleryState.activeIndex = 0;
|
||||
return `
|
||||
<div class="no-examples">
|
||||
<p>All example images are filtered due to NSFW content settings</p>
|
||||
<p class="nsfw-filter-info">Your settings are currently set to show only safe-for-work content</p>
|
||||
<p>You can change this in Settings <i class="fas fa-cog"></i></p>
|
||||
<p>${translate('modals.model.showcase.allFiltered', {}, 'All example images are filtered due to NSFW content settings')}</p>
|
||||
<p class="nsfw-filter-info">${translate('modals.model.showcase.sfwOnlyEnabled', {}, 'Your settings are currently set to show only safe-for-work content')}</p>
|
||||
<p>${translate('modals.model.showcase.changeInSettings', {}, 'You can change this in Settings')} <i class="fas fa-cog"></i></p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
galleryState.images = filteredImages;
|
||||
if (galleryState.activeIndex >= filteredImages.length || galleryState.activeIndex < 0) {
|
||||
galleryState.activeIndex = 0;
|
||||
}
|
||||
|
||||
// Show hidden content notification if applicable
|
||||
const hiddenNotification = hiddenCount > 0 ?
|
||||
`<div class="nsfw-filter-notification">
|
||||
<i class="fas fa-eye-slash"></i> ${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting
|
||||
`<span class="nsfw-filter-notification">
|
||||
<i class="fas fa-eye-slash"></i> ${translate('modals.model.showcase.hiddenBySfw', { count: hiddenCount }, `${hiddenCount} hidden by SFW-only setting`)}
|
||||
</span>` : '';
|
||||
|
||||
const exampleImagesPath = state.global.settings.example_images_path;
|
||||
const isPathConfigured = exampleImagesPath && exampleImagesPath.trim() !== '';
|
||||
const count = filteredImages.length;
|
||||
|
||||
const importZone = isPathConfigured ? `<div class="gallery-import-zone hidden" id="galleryImportZone">
|
||||
${renderImportInterface(false)}
|
||||
</div>` : '';
|
||||
|
||||
// Collapsed resting state: a slim indicator bar only — remote examples are
|
||||
// not rendered (and therefore not fetched) until the user expands.
|
||||
if (!expanded) {
|
||||
const showText = translate('modals.model.showcase.showExamples', {}, 'Show examples');
|
||||
return `
|
||||
<div class="scroll-indicator">
|
||||
<i class="fas fa-chevron-${startExpanded ? 'up' : 'down'}"></i>
|
||||
<span>Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples</span>
|
||||
</div>
|
||||
<div class="carousel ${startExpanded ? '' : 'collapsed'}">
|
||||
<div class="showcase-gallery">
|
||||
<div class="gallery-indicator-bar">
|
||||
${renderPreviewThumb(previewUrl)}
|
||||
<button class="gallery-show-btn" id="galleryShowBtn">
|
||||
<i class="fas fa-chevron-down"></i> ${translate('modals.model.showcase.showCount', { count }, `${showText} (${count})`)}
|
||||
</button>
|
||||
${hiddenNotification}
|
||||
<div class="carousel-container">
|
||||
${filteredImages.map((img, index) => renderMediaItem(img, index, exampleFiles)).join('')}
|
||||
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
|
||||
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
|
||||
</button>
|
||||
</div>
|
||||
${importZone}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
${renderImportInterface(false)}
|
||||
const showNav = count > 1;
|
||||
const positionText = `${galleryState.activeIndex + 1} / ${count}`;
|
||||
const activeImg = filteredImages[galleryState.activeIndex];
|
||||
const mediaAspect = mediaAspectRatio(activeImg);
|
||||
|
||||
return `
|
||||
<div class="showcase-gallery">
|
||||
<div class="gallery-toolbar">
|
||||
${hiddenNotification}
|
||||
<button class="gallery-show-btn" id="galleryShowBtn">
|
||||
<i class="fas fa-chevron-up"></i> ${translate('modals.model.showcase.hideExamples', {}, 'Hide examples')}
|
||||
</button>
|
||||
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
|
||||
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="gallery-main">
|
||||
<div class="main-media-container" id="mainMediaContainer" style="--media-aspect: ${mediaAspect}">
|
||||
${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)}
|
||||
${renderPositionBadge(positionText)}
|
||||
</div>
|
||||
${showNav ? `<button class="gallery-nav prev" id="galleryPrevBtn" title="${translate('modals.model.showcase.previousExample', {}, 'Previous example')}">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<button class="gallery-nav next" id="galleryNextBtn" title="${translate('modals.model.showcase.nextExample', {}, 'Next example')}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
<div class="gallery-strip" id="galleryStrip">
|
||||
${filteredImages.map((img, index) => renderThumbnail(img, index, exampleFiles)).join('')}
|
||||
</div>
|
||||
${importZone}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single media item (image or video)
|
||||
* Render the position badge that floats over the main media
|
||||
* @param {string} positionText - e.g. "3 / 10"
|
||||
* @returns {string} HTML for the badge
|
||||
*/
|
||||
function renderPositionBadge(positionText) {
|
||||
return `<span class="gallery-position-badge" id="galleryPosition">${positionText}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the aspect ratio (w/h) for the main viewer, falling back to 4:3
|
||||
* when dimensions are missing (prevents NaN layout)
|
||||
* @param {Object} img - Image/video metadata
|
||||
* @returns {number} width / height
|
||||
*/
|
||||
function mediaAspectRatio(img) {
|
||||
const w = img?.width || 4;
|
||||
const h = img?.height || 3;
|
||||
return w / h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a thumbnail for the gallery strip
|
||||
* @param {Object} img - Image/video metadata
|
||||
* @param {number} index - Index in the array
|
||||
* @param {Array} exampleFiles - Local files
|
||||
* @returns {string} HTML for the thumbnail button
|
||||
*/
|
||||
function renderThumbnail(img, index, exampleFiles) {
|
||||
const localFile = findLocalFile(img, index, exampleFiles);
|
||||
|
||||
const originalRemoteUrl = img.url || '';
|
||||
const isVideo = localFile ? localFile.is_video :
|
||||
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
const thumbUrl = localFile ? localFile.path : getThumbnailUrl(originalRemoteUrl, mediaType);
|
||||
|
||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
const activeClass = index === galleryState.activeIndex ? ' active' : '';
|
||||
const blurClass = shouldBlur ? ' blurred' : '';
|
||||
const mediaHtml = isVideo ?
|
||||
`<video class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" muted playsinline preload="metadata"></video>
|
||||
<i class="fas fa-play thumb-video-badge"></i>` :
|
||||
`<img class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" loading="lazy" alt="">`;
|
||||
const nsfwBadge = shouldBlur ? '<i class="fas fa-eye-slash thumb-nsfw-badge"></i>' : '';
|
||||
|
||||
return `<button class="gallery-thumb${activeClass}" data-index="${index}">${mediaHtml}${nsfwBadge}</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the active media item in the main viewer
|
||||
* @param {Object} img - Image/video metadata
|
||||
* @param {number} index - Index in the array
|
||||
* @param {Array} exampleFiles - Local files
|
||||
@@ -173,19 +316,6 @@ function renderMediaItem(img, index, exampleFiles) {
|
||||
|
||||
const localUrl = localFile ? localFile.path : '';
|
||||
|
||||
// Calculate appropriate aspect ratio
|
||||
// Defensive fallback: 0 width/height → 4:3 default (prevents NaN layout)
|
||||
const safeW = img.width || 4;
|
||||
const safeH = img.height || 3;
|
||||
const aspectRatio = (safeH / safeW) * 100;
|
||||
const containerWidth = 800; // modal content maximum width
|
||||
const minHeightPercent = 40;
|
||||
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
||||
const heightPercent = Math.max(
|
||||
minHeightPercent,
|
||||
Math.min(maxHeightPercent, aspectRatio)
|
||||
);
|
||||
|
||||
// Extract CivitAI image ID from CDN URL for import status check
|
||||
const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || '';
|
||||
|
||||
@@ -195,13 +325,13 @@ function renderMediaItem(img, index, exampleFiles) {
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
let nsfwText = translate('modals.model.showcase.nsfwMature', {}, 'Mature Content');
|
||||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = "XXX-rated Content";
|
||||
nsfwText = translate('modals.model.showcase.nsfwXxx', {}, 'XXX-rated Content');
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||
nsfwText = "X-rated Content";
|
||||
nsfwText = translate('modals.model.showcase.nsfwX', {}, 'X-rated Content');
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||
nsfwText = "R-rated Content";
|
||||
nsfwText = translate('modals.model.showcase.nsfwR', {}, 'R-rated Content');
|
||||
}
|
||||
|
||||
// Extract metadata from the image
|
||||
@@ -270,13 +400,13 @@ function renderMediaItem(img, index, exampleFiles) {
|
||||
// Generate the appropriate wrapper based on media type
|
||||
if (isVideo) {
|
||||
return generateVideoWrapper(
|
||||
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||
img, shouldBlur, nsfwText, metadataPanel,
|
||||
localUrl, remoteUrl, mediaControlsHtml
|
||||
);
|
||||
}
|
||||
|
||||
return generateImageWrapper(
|
||||
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||
img, shouldBlur, nsfwText, metadataPanel,
|
||||
localUrl, remoteUrl, mediaControlsHtml
|
||||
);
|
||||
}
|
||||
@@ -308,6 +438,264 @@ function findLocalFile(img, index, exampleFiles) {
|
||||
return localFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the main viewer to another example (wraps around)
|
||||
* @param {number} index - Target index in galleryState.images
|
||||
*/
|
||||
export function updateMainDisplay(index) {
|
||||
const count = galleryState.images.length;
|
||||
if (!count || !galleryState.expanded) return;
|
||||
|
||||
galleryState.activeIndex = ((index % count) + count) % count;
|
||||
|
||||
const container = document.getElementById('mainMediaContainer');
|
||||
if (!container) return;
|
||||
|
||||
const activeImg = galleryState.images[galleryState.activeIndex];
|
||||
container.style.setProperty('--media-aspect', mediaAspectRatio(activeImg));
|
||||
// The badge lives inside the container, so rebuild it together with the media
|
||||
container.innerHTML = renderMediaItem(
|
||||
activeImg,
|
||||
galleryState.activeIndex,
|
||||
galleryState.exampleFiles
|
||||
) + renderPositionBadge(`${galleryState.activeIndex + 1} / ${count}`);
|
||||
|
||||
// Update thumbnail active state and scroll it into view
|
||||
document.querySelectorAll('.gallery-strip .gallery-thumb').forEach(thumb => {
|
||||
const isActive = Number(thumb.dataset.index) === galleryState.activeIndex;
|
||||
thumb.classList.toggle('active', isActive);
|
||||
if (isActive) {
|
||||
thumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
});
|
||||
|
||||
initMainMediaInteractions(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the item list for the full-size media viewer from current gallery state
|
||||
* @returns {Array<{url: string, type: string}>}
|
||||
*/
|
||||
function buildViewerItems() {
|
||||
return galleryState.images.map((img, index) => {
|
||||
const localFile = findLocalFile(img, index, galleryState.exampleFiles);
|
||||
const originalRemoteUrl = img.url || '';
|
||||
const isVideo = localFile ? localFile.is_video :
|
||||
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
||||
return {
|
||||
url: localFile?.path || getShowcaseUrl(originalRemoteUrl, isVideo ? 'video' : 'image'),
|
||||
type: isVideo ? 'video' : 'image'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up interactions for the media currently shown in the main viewer
|
||||
* @param {HTMLElement} container - The main media container
|
||||
*/
|
||||
function initMainMediaInteractions(container) {
|
||||
initLazyLoading(container);
|
||||
initNsfwBlurHandlers(container);
|
||||
initMetadataPanelHandlers(container);
|
||||
initMediaControlHandlers(container);
|
||||
positionAllMediaControls(container);
|
||||
|
||||
// Hoist the metadata panel to the gallery-main level so it spans the full
|
||||
// column width (legacy behavior) instead of being squeezed to the media's
|
||||
// width. Handler references stay valid — they are bound to the element.
|
||||
const panel = container.querySelector('.image-metadata-panel');
|
||||
const galleryMain = container.closest('.gallery-main');
|
||||
if (panel && galleryMain) {
|
||||
// Drop the panel of the previously displayed item, if any
|
||||
galleryMain.querySelectorAll(':scope > .image-metadata-panel').forEach(p => p.remove());
|
||||
galleryMain.appendChild(panel);
|
||||
}
|
||||
|
||||
// Click-to-view: open full-size media viewer at the active index
|
||||
const mediaEl = container.querySelector('.media-wrapper img, .media-wrapper video');
|
||||
if (mediaEl) {
|
||||
mediaEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openMediaViewer(buildViewerItems(), galleryState.activeIndex);
|
||||
});
|
||||
}
|
||||
|
||||
// Reposition controls once media dimensions are known
|
||||
container.querySelectorAll('img, video').forEach(media => {
|
||||
media.addEventListener('load', () => positionAllMediaControls(container));
|
||||
if (media.tagName === 'VIDEO') {
|
||||
media.addEventListener('loadedmetadata', () => positionAllMediaControls(container));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to top of modal content
|
||||
* @param {HTMLElement} button - Back to top button
|
||||
*/
|
||||
export function scrollToTop(button) {
|
||||
const modalContent = button.closest('.modal-content');
|
||||
if (modalContent) {
|
||||
modalContent.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the inline import zone; without a configured path, open settings instead
|
||||
* @param {HTMLElement} gallery - The gallery root element
|
||||
*/
|
||||
function toggleImportZone(gallery) {
|
||||
const exampleImagesPath = state.global.settings.example_images_path;
|
||||
const isPathConfigured = exampleImagesPath && exampleImagesPath.trim() !== '';
|
||||
if (!isPathConfigured) {
|
||||
openSettingsForExampleImages();
|
||||
return;
|
||||
}
|
||||
gallery.querySelector('.gallery-import-zone')?.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a deleted custom example from the gallery and re-render
|
||||
* @param {string} shortId - Custom image short id
|
||||
*/
|
||||
function handleExampleDeleted(shortId) {
|
||||
const isDeleted = (img) => img.id === shortId;
|
||||
galleryState.rawImages = galleryState.rawImages.filter(img => !isDeleted(img));
|
||||
galleryState.images = galleryState.images.filter(img => !isDeleted(img));
|
||||
galleryState.exampleFiles = galleryState.exampleFiles.filter(
|
||||
file => !file.name.startsWith(`custom_${shortId}`)
|
||||
);
|
||||
if (galleryState.activeIndex >= galleryState.images.length) {
|
||||
galleryState.activeIndex = Math.max(0, galleryState.images.length - 1);
|
||||
}
|
||||
|
||||
rerenderGallery(galleryState.expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render the gallery in place from current state and rebind everything
|
||||
* @param {boolean} expanded - Whether the re-rendered gallery starts expanded
|
||||
*/
|
||||
function rerenderGallery(expanded) {
|
||||
const showcaseTab = document.getElementById('showcase-tab');
|
||||
if (!showcaseTab) return;
|
||||
|
||||
showcaseTab.innerHTML = renderShowcaseContent(
|
||||
galleryState.rawImages,
|
||||
galleryState.exampleFiles,
|
||||
galleryState.previewUrl,
|
||||
expanded
|
||||
);
|
||||
|
||||
const gallery = showcaseTab.querySelector('.showcase-gallery');
|
||||
if (gallery) {
|
||||
initShowcaseContent(gallery);
|
||||
}
|
||||
|
||||
const modelHash = document.querySelector('.showcase-section')?.dataset.modelHash;
|
||||
if (modelHash) {
|
||||
initExampleImport(modelHash, showcaseTab);
|
||||
}
|
||||
}
|
||||
|
||||
// Track the gallery whose controls need repositioning on window resize
|
||||
let resizeBoundGallery = null;
|
||||
|
||||
// Scroll-to-expand: expands the collapsed gallery when the user keeps
|
||||
// scrolling down near the bottom of the modal (legacy muscle memory)
|
||||
let scrollExpandTarget = null;
|
||||
|
||||
function setupScrollToExpand(gallery) {
|
||||
const modalContent = gallery.closest('.modal-content');
|
||||
if (!modalContent) return;
|
||||
if (scrollExpandTarget === modalContent) return; // already bound
|
||||
scrollExpandTarget = modalContent;
|
||||
|
||||
modalContent.addEventListener('wheel', (event) => {
|
||||
if (galleryState.expanded || !galleryState.images.length) return;
|
||||
if (event.deltaY <= 0) return;
|
||||
const nearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
|
||||
if (nearBottom) {
|
||||
rerenderGallery(true);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all gallery interactions
|
||||
* @param {HTMLElement} gallery - The .showcase-gallery element
|
||||
*/
|
||||
export function initShowcaseContent(gallery) {
|
||||
if (!gallery) return;
|
||||
|
||||
// While expanded the thumbnail strip occupies the modal's bottom-right
|
||||
// corner; hide the back-to-top button there (Hide examples is the
|
||||
// equivalent "return to top" affordance)
|
||||
gallery.closest('.modal-content')?.classList.toggle('showcase-expanded', galleryState.expanded);
|
||||
|
||||
// Toolbar: show/hide toggle (expanding renders the gallery and starts remote loads)
|
||||
gallery.querySelector('#galleryShowBtn')?.addEventListener('click', () => {
|
||||
rerenderGallery(!galleryState.expanded);
|
||||
});
|
||||
|
||||
// Same expansion via mouse wheel near the bottom of the modal
|
||||
setupScrollToExpand(gallery);
|
||||
|
||||
// Toolbar: import toggle; scroll the freshly opened zone into view
|
||||
gallery.querySelector('#galleryImportBtn')?.addEventListener('click', () => {
|
||||
toggleImportZone(gallery);
|
||||
const zone = gallery.querySelector('.gallery-import-zone:not(.hidden)');
|
||||
zone?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
|
||||
// Prev/next navigation (wraps around)
|
||||
gallery.querySelector('#galleryPrevBtn')?.addEventListener('click', () => {
|
||||
updateMainDisplay(galleryState.activeIndex - 1);
|
||||
});
|
||||
gallery.querySelector('#galleryNextBtn')?.addEventListener('click', () => {
|
||||
updateMainDisplay(galleryState.activeIndex + 1);
|
||||
});
|
||||
|
||||
// Thumbnail strip: click to select, wheel scrolls horizontally
|
||||
gallery.querySelectorAll('.gallery-thumb').forEach(thumb => {
|
||||
thumb.addEventListener('click', () => {
|
||||
updateMainDisplay(Number(thumb.dataset.index));
|
||||
});
|
||||
});
|
||||
const strip = gallery.querySelector('.gallery-strip');
|
||||
if (strip) {
|
||||
strip.addEventListener('wheel', (e) => {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; // let native horizontal scrolling through
|
||||
e.preventDefault();
|
||||
strip.scrollLeft += e.deltaY;
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// Custom example deleted elsewhere (media controls) → refresh gallery
|
||||
gallery.addEventListener('example-media-deleted', (e) => {
|
||||
handleExampleDeleted(e.detail?.shortId);
|
||||
});
|
||||
|
||||
// Main viewer interactions (only exists in the expanded state)
|
||||
const container = gallery.querySelector('.main-media-container');
|
||||
if (container && galleryState.expanded) {
|
||||
initMainMediaInteractions(container);
|
||||
}
|
||||
|
||||
// Reposition controls on window resize
|
||||
resizeBoundGallery = gallery;
|
||||
}
|
||||
|
||||
// Bind the resize handler once; it always repositions the latest gallery
|
||||
window.addEventListener('resize', () => {
|
||||
if (resizeBoundGallery && resizeBoundGallery.isConnected) {
|
||||
positionAllMediaControls(resizeBoundGallery);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Render the import interface for example images
|
||||
* @param {boolean} isEmpty - Whether there are no existing examples
|
||||
@@ -353,20 +741,22 @@ function renderImportInterface(isEmpty) {
|
||||
<div class="import-container" id="exampleImportContainer">
|
||||
<div class="import-placeholder">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<h3>${isEmpty ? 'No example images available' : 'Add more examples'}</h3>
|
||||
<p>Drag & drop images or videos here</p>
|
||||
<p class="sub-text">or</p>
|
||||
<h3>${isEmpty
|
||||
? translate('modals.model.showcase.noExamples', {}, 'No example images available')
|
||||
: translate('modals.model.showcase.addMoreExamples', {}, 'Add more examples')}</h3>
|
||||
<p>${translate('modals.model.showcase.dragDrop', {}, 'Drag & drop images or videos here')}</p>
|
||||
<p class="sub-text">${translate('modals.model.showcase.or', {}, 'or')}</p>
|
||||
<button class="select-files-btn" id="selectExampleFilesBtn">
|
||||
<i class="fas fa-folder-open"></i> Select Files
|
||||
<i class="fas fa-folder-open"></i> ${translate('modals.model.showcase.selectFiles', {}, 'Select Files')}
|
||||
</button>
|
||||
<p class="import-formats">Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm</p>
|
||||
<p class="import-formats">${translate('modals.model.showcase.supportedFormats', {}, 'Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm')}</p>
|
||||
</div>
|
||||
<input type="file" id="exampleFilesInput" multiple accept="image/*,image/avif,image/jxl,video/mp4,video/webm" style="display: none;">
|
||||
<div class="import-progress-container" style="display: none;">
|
||||
<div class="import-progress">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
<span class="progress-text">Importing files...</span>
|
||||
<span class="progress-text">${translate('modals.model.showcase.importing', {}, 'Importing files...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,7 +877,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
alert('No supported files selected. Please select image or video files.');
|
||||
showToast('modals.model.showcase.noSupportedFiles', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -535,7 +925,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
throw new Error(updatedFilesResult.error || 'Failed to get updated file list');
|
||||
}
|
||||
|
||||
// Re-render the showcase content
|
||||
// Re-render the showcase content, expanded so the user sees the result
|
||||
const showcaseTab = document.getElementById('showcase-tab');
|
||||
if (showcaseTab) {
|
||||
// Get the updated images from the result
|
||||
@@ -543,12 +933,16 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
const customImages = result.custom_images || [];
|
||||
// Combine both arrays for rendering
|
||||
const allImages = [...regularImages, ...customImages];
|
||||
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, true);
|
||||
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, galleryState.previewUrl, true);
|
||||
|
||||
// Re-initialize showcase functionality
|
||||
const carousel = showcaseTab.querySelector('.carousel');
|
||||
if (carousel && !carousel.classList.contains('collapsed')) {
|
||||
initShowcaseContent(carousel);
|
||||
// Re-initialize gallery functionality
|
||||
const gallery = showcaseTab.querySelector('.showcase-gallery');
|
||||
if (gallery) {
|
||||
initShowcaseContent(gallery);
|
||||
// Select the most recently imported example
|
||||
updateMainDisplay(galleryState.images.length - 1);
|
||||
// Keep the import zone expanded so multi-file imports can continue
|
||||
gallery.querySelector('.gallery-import-zone')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Initialize the import UI for the new content
|
||||
@@ -579,239 +973,3 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
showToast('toast.import.importFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle showcase expansion
|
||||
* @param {HTMLElement} element - The scroll indicator element
|
||||
*/
|
||||
export function toggleShowcase(element) {
|
||||
const carousel = element.nextElementSibling;
|
||||
const isCollapsed = carousel.classList.contains('collapsed');
|
||||
const indicator = element.querySelector('span');
|
||||
const icon = element.querySelector('i');
|
||||
|
||||
carousel.classList.toggle('collapsed');
|
||||
|
||||
if (isCollapsed) {
|
||||
const count = carousel.querySelectorAll('.media-wrapper').length;
|
||||
indicator.textContent = `Scroll or click to hide examples`;
|
||||
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
|
||||
initShowcaseContent(carousel);
|
||||
} else {
|
||||
const count = carousel.querySelectorAll('.media-wrapper').length;
|
||||
indicator.textContent = `Scroll or click to show ${count} examples`;
|
||||
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
|
||||
|
||||
// Make sure any open metadata panels get closed
|
||||
const carouselContainer = carousel.querySelector('.carousel-container');
|
||||
if (carouselContainer) {
|
||||
carouselContainer.style.height = '0';
|
||||
setTimeout(() => {
|
||||
carouselContainer.style.height = '';
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind scroll-indicator click events (works even when carousel is collapsed)
|
||||
* @param {HTMLElement} carousel - The carousel element
|
||||
*/
|
||||
function bindScrollIndicatorEvents(carousel) {
|
||||
if (!carousel) return;
|
||||
|
||||
const scrollIndicator = carousel.previousElementSibling;
|
||||
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) {
|
||||
// Remove previous listeners to avoid duplicates
|
||||
scrollIndicator.onclick = null;
|
||||
scrollIndicator.removeEventListener('click', scrollIndicator._leftClickHandler);
|
||||
scrollIndicator.removeEventListener('mousedown', scrollIndicator._middleClickHandler);
|
||||
|
||||
// Handler for left-click (button 0) - uses 'click' event
|
||||
scrollIndicator._leftClickHandler = (event) => {
|
||||
if (event.button === 0) {
|
||||
event.preventDefault();
|
||||
toggleShowcase(scrollIndicator);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for middle-click (button 1) - uses 'mousedown' event
|
||||
scrollIndicator._middleClickHandler = (event) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
toggleShowcase(scrollIndicator);
|
||||
}
|
||||
};
|
||||
|
||||
scrollIndicator.addEventListener('click', scrollIndicator._leftClickHandler);
|
||||
scrollIndicator.addEventListener('mousedown', scrollIndicator._middleClickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all showcase content interactions
|
||||
* @param {HTMLElement} carousel - The carousel element
|
||||
*/
|
||||
export function initShowcaseContent(carousel) {
|
||||
if (!carousel) return;
|
||||
|
||||
initLazyLoading(carousel);
|
||||
initNsfwBlurHandlers(carousel);
|
||||
initMetadataPanelHandlers(carousel);
|
||||
initMediaControlHandlers(carousel);
|
||||
positionAllMediaControls(carousel);
|
||||
|
||||
// Click-to-view: open full-size media viewer when clicking showcase images/videos
|
||||
const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video');
|
||||
const allItems = [];
|
||||
const elementIndexMap = new Map();
|
||||
viewerElements.forEach((el) => {
|
||||
const isVideo = el.tagName === 'VIDEO';
|
||||
const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc;
|
||||
if (url) {
|
||||
elementIndexMap.set(el, allItems.length);
|
||||
allItems.push({ url, type: isVideo ? 'video' : 'image' });
|
||||
}
|
||||
});
|
||||
viewerElements.forEach((mediaEl) => {
|
||||
const idx = elementIndexMap.get(mediaEl);
|
||||
if (idx === undefined) return;
|
||||
mediaEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openMediaViewer(allItems, idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Bind scroll-indicator click events
|
||||
bindScrollIndicatorEvents(carousel);
|
||||
|
||||
// Add window resize handler
|
||||
const resizeHandler = () => positionAllMediaControls(carousel);
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
// Handle images loading which might change dimensions
|
||||
const mediaElements = carousel.querySelectorAll('img, video');
|
||||
mediaElements.forEach(media => {
|
||||
media.addEventListener('load', () => positionAllMediaControls(carousel));
|
||||
if (media.tagName === 'VIDEO') {
|
||||
media.addEventListener('loadedmetadata', () => positionAllMediaControls(carousel));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to top of modal content
|
||||
* @param {HTMLElement} button - Back to top button
|
||||
*/
|
||||
export function scrollToTop(button) {
|
||||
const modalContent = button.closest('.modal-content');
|
||||
if (modalContent) {
|
||||
modalContent.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up showcase scroll functionality
|
||||
* @param {string} modalId - ID of the modal element
|
||||
*/
|
||||
export function setupShowcaseScroll(modalId) {
|
||||
const wheelOptions = { passive: false };
|
||||
const wheelHandler = (event) => {
|
||||
const modalContent = document.querySelector(`#${modalId} .modal-content`);
|
||||
if (!modalContent) return;
|
||||
|
||||
const showcase = modalContent.querySelector('.showcase-section');
|
||||
if (!showcase) return;
|
||||
|
||||
const carousel = showcase.querySelector('.carousel');
|
||||
const scrollIndicator = showcase.querySelector('.scroll-indicator');
|
||||
|
||||
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
|
||||
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
|
||||
|
||||
if (isNearBottom) {
|
||||
toggleShowcase(scrollIndicator);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('wheel', wheelHandler, wheelOptions);
|
||||
showcaseListenerMetrics.wheelListeners += 1;
|
||||
|
||||
// Use MutationObserver to set up back-to-top button when modal content is added
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal && modal.querySelector('.modal-content')) {
|
||||
setupBackToTopButton(modal.querySelector('.modal-content'));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
showcaseListenerMetrics.mutationObservers += 1;
|
||||
|
||||
// Try to set up the button immediately in case the modal is already open
|
||||
const modalContent = document.querySelector(`#${modalId} .modal-content`);
|
||||
if (modalContent) {
|
||||
setupBackToTopButton(modalContent);
|
||||
}
|
||||
|
||||
let cleanedUp = false;
|
||||
|
||||
return () => {
|
||||
if (cleanedUp) {
|
||||
return;
|
||||
}
|
||||
cleanedUp = true;
|
||||
document.removeEventListener('wheel', wheelHandler, wheelOptions);
|
||||
showcaseListenerMetrics.wheelListeners -= 1;
|
||||
observer.disconnect();
|
||||
showcaseListenerMetrics.mutationObservers -= 1;
|
||||
const modalContent = document.querySelector(`#${modalId} .modal-content`);
|
||||
teardownBackToTopButton(modalContent);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up back-to-top button
|
||||
* @param {HTMLElement} modalContent - Modal content element
|
||||
*/
|
||||
function setupBackToTopButton(modalContent) {
|
||||
teardownBackToTopButton(modalContent);
|
||||
|
||||
const handler = () => {
|
||||
const backToTopBtn = modalContent.querySelector('.back-to-top');
|
||||
if (backToTopBtn) {
|
||||
if (modalContent.scrollTop > 300) {
|
||||
backToTopBtn.classList.add('visible');
|
||||
} else {
|
||||
backToTopBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
modalContent._backToTopScrollHandler = handler;
|
||||
modalContent.addEventListener('scroll', handler);
|
||||
showcaseListenerMetrics.backToTopHandlers += 1;
|
||||
handler();
|
||||
}
|
||||
|
||||
function teardownBackToTopButton(modalContent) {
|
||||
if (!modalContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingHandler = modalContent._backToTopScrollHandler;
|
||||
if (existingHandler) {
|
||||
modalContent.removeEventListener('scroll', existingHandler);
|
||||
delete modalContent._backToTopScrollHandler;
|
||||
showcaseListenerMetrics.backToTopHandlers -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ export const BASE_MODELS = {
|
||||
UNKNOWN: "Other"
|
||||
};
|
||||
|
||||
// Custom dataTransfer MIME type tagging internal model-card drags (move-to-folder).
|
||||
// Preview-drop handlers use it to ignore drags that did not come from the OS file system.
|
||||
export const MODEL_CARD_DRAG_MIME_TYPE = 'application/x-lora-manager-model-card';
|
||||
|
||||
// Model sub-type display names (new canonical field: sub_type)
|
||||
export const MODEL_SUBTYPE_DISPLAY_NAMES = {
|
||||
// LoRA sub-types
|
||||
|
||||
@@ -4,20 +4,26 @@
|
||||
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<!-- Header Actions: populated dynamically in RecipeModal.js -->
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
||||
<!-- Header Actions: Send button is static; source URL button is appended dynamically in RecipeModal.js -->
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>{{ t('recipes.actions.sendRecipe') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- Top Section: Preview and Generation Parameters -->
|
||||
<div class="recipe-top-section">
|
||||
<!-- Left Column: Preview -->
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
<!-- Source URL elements are now added dynamically in RecipeModal.js -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center Column: Generation Parameters -->
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-header-row">
|
||||
<h3>Generation Parameters</h3>
|
||||
@@ -103,9 +109,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Section: Resources -->
|
||||
<!-- Right Column: Resources -->
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -114,12 +119,6 @@
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button class="action-btn send-recipe-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-resources-list">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname;
|
||||
const MEDIA_VIEWER_MODULE = new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname;
|
||||
|
||||
function setupDom() {
|
||||
document.body.innerHTML = `
|
||||
<div id="modelModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<img class="media-wrapper" src="" alt="">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('MediaViewer Escape handling', () => {
|
||||
let ModalManager;
|
||||
let manager;
|
||||
let openMediaViewer;
|
||||
let isMediaViewerOpen;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
setupDom();
|
||||
window.scrollTo = vi.fn();
|
||||
({ ModalManager } = await import(MODAL_MANAGER_MODULE));
|
||||
manager = new ModalManager();
|
||||
manager.initialize();
|
||||
({ openMediaViewer, isMediaViewerOpen } = await import(MEDIA_VIEWER_MODULE));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('closes only the media viewer, not the underlying modal, on Escape', () => {
|
||||
manager.showModal('modelModal');
|
||||
expect(manager.getModal('modelModal').isOpen).toBe(true);
|
||||
|
||||
openMediaViewer('https://example.com/image.png');
|
||||
expect(isMediaViewerOpen()).toBe(true);
|
||||
|
||||
// Dispatch on document.body (real keydown target is the focused element,
|
||||
// never document itself) so the capture handler fires before the bubble one.
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
expect(isMediaViewerOpen()).toBe(false);
|
||||
expect(manager.getModal('modelModal').isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('still lets Escape close the modal when no viewer is open', () => {
|
||||
manager.showModal('modelModal');
|
||||
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
expect(manager.getModal('modelModal').isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -246,13 +246,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -284,7 +290,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -293,9 +298,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -370,13 +372,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -408,7 +416,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -417,9 +424,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -464,13 +468,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -502,7 +512,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -511,9 +520,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -573,13 +579,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -611,7 +623,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -620,9 +631,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -662,13 +670,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -700,7 +714,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -709,9 +722,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -765,13 +775,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -803,7 +819,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -812,9 +827,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -885,13 +897,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -923,7 +941,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -932,9 +949,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -1019,13 +1033,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -1057,7 +1077,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div id="recipeCheckpoint"></div>
|
||||
<div id="recipeResourceDivider"></div>
|
||||
@@ -1068,9 +1087,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -1138,7 +1154,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div id="recipeLorasList"></div>
|
||||
<span id="recipeLorasCount"></span>
|
||||
<button id="viewRecipeLorasBtn"></button>
|
||||
<button id="copyRecipeSyntaxBtn"></button>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1191,7 +1207,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div id="recipeLorasList"></div>
|
||||
<span id="recipeLorasCount"></span>
|
||||
<button id="viewRecipeLorasBtn"></button>
|
||||
<button id="copyRecipeSyntaxBtn"></button>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1255,13 +1271,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -1293,7 +1315,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div id="recipeCheckpoint"></div>
|
||||
<div id="recipeResourceDivider"></div>
|
||||
@@ -1304,9 +1325,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -1368,13 +1386,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -1406,7 +1430,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div id="recipeCheckpoint"></div>
|
||||
<div id="recipeResourceDivider"></div>
|
||||
@@ -1417,9 +1440,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -1486,13 +1506,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -1524,7 +1550,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -1533,9 +1558,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -1594,13 +1616,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -1632,7 +1660,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -1641,9 +1668,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -1711,13 +1735,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -1749,7 +1779,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -1758,9 +1787,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
@@ -1808,13 +1834,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -1846,7 +1878,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
@@ -1932,13 +1963,19 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
@@ -1970,7 +2007,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MODEL_CARD_DRAG_MIME_TYPE } from '../../../static/js/utils/constants.js';
|
||||
|
||||
const {
|
||||
MODEL_CARD_MODULE,
|
||||
@@ -108,9 +109,9 @@ describe('ModelCard drag & drop preview upload', () => {
|
||||
return createModelCard(model, 'loras');
|
||||
}
|
||||
|
||||
function dispatchDrop(card, files) {
|
||||
function dispatchDrop(card, files, types = []) {
|
||||
const event = new Event('drop', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { files } });
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { files, types } });
|
||||
card.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
@@ -179,4 +180,41 @@ describe('ModelCard drag & drop preview upload', () => {
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(card.classList.contains('drag-over')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores drops tagged as internal card drags (move-to-folder)', () => {
|
||||
const card = createCard();
|
||||
const file = new File(['data'], 'preview.png', { type: 'image/png' });
|
||||
|
||||
const event = dispatchDrop(card, [file], [MODEL_CARD_DRAG_MIME_TYPE]);
|
||||
|
||||
expect(uploadPreviewMock).not.toHaveBeenCalled();
|
||||
expect(showToastMock).not.toHaveBeenCalled();
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(card.classList.contains('drag-over')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not highlight or intercept internal card drags during dragover', () => {
|
||||
const card = createCard();
|
||||
|
||||
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(dragOverEvent, 'dataTransfer', {
|
||||
value: { types: [MODEL_CARD_DRAG_MIME_TYPE] },
|
||||
});
|
||||
card.dispatchEvent(dragOverEvent);
|
||||
|
||||
expect(dragOverEvent.defaultPrevented).toBe(false);
|
||||
expect(card.classList.contains('drag-over')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the card draggable (move-to-folder) but the preview image non-draggable', () => {
|
||||
const card = createCard();
|
||||
|
||||
// The card itself must stay draggable for sidebar move-to-folder drags.
|
||||
expect(card.draggable).toBe(true);
|
||||
// The preview image must not start a native image drag: the browser would
|
||||
// synthesize a File payload from it, which the drop handler would mistake
|
||||
// for an external preview replacement.
|
||||
const img = card.querySelector('.card-preview img');
|
||||
expect(img.getAttribute('draggable')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,8 +45,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
}));
|
||||
|
||||
vi.mock(SHOWCASE_MODULE, () => ({
|
||||
toggleShowcase: vi.fn(),
|
||||
setupShowcaseScroll: vi.fn(),
|
||||
scrollToTop: vi.fn(),
|
||||
loadExampleImages: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -43,8 +43,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
}));
|
||||
|
||||
vi.mock(SHOWCASE_MODULE, () => ({
|
||||
toggleShowcase: vi.fn(),
|
||||
setupShowcaseScroll: vi.fn(),
|
||||
scrollToTop: vi.fn(),
|
||||
loadExampleImages: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
||||
|
||||
const { SHOWCASE_MODULE, MEDIA_UTILS_MODULE, MEDIA_VIEWER_MODULE } = vi.hoisted(() => ({
|
||||
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
||||
MEDIA_UTILS_MODULE: new URL('../../../static/js/components/shared/showcase/MediaUtils.js', import.meta.url).pathname,
|
||||
MEDIA_VIEWER_MODULE: new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(MEDIA_UTILS_MODULE, () => ({
|
||||
initLazyLoading: vi.fn(),
|
||||
initNsfwBlurHandlers: vi.fn(),
|
||||
initMetadataPanelHandlers: vi.fn(),
|
||||
initMediaControlHandlers: vi.fn(),
|
||||
positionAllMediaControls: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MEDIA_VIEWER_MODULE, () => ({
|
||||
openMediaViewer: vi.fn(),
|
||||
}));
|
||||
|
||||
const PREVIEW_URL = '/loras_static/preview/abc.png';
|
||||
|
||||
const IMAGES = [
|
||||
{ url: 'https://image.civitai.com/abc/111.jpeg', width: 512, height: 768, nsfwLevel: 0 },
|
||||
{ url: 'https://image.civitai.com/abc/222.jpeg', width: 768, height: 512, nsfwLevel: 0 },
|
||||
{ url: 'https://image.civitai.com/abc/333.mp4', width: 512, height: 512, nsfwLevel: 0 },
|
||||
];
|
||||
|
||||
describe('Showcase gallery', () => {
|
||||
let state;
|
||||
|
||||
beforeEach(async () => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
state = stateModule.state;
|
||||
state.settings.show_only_sfw = false;
|
||||
state.settings.blur_mature_content = false;
|
||||
state.global.settings.example_images_path = '/tmp/examples';
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('starts collapsed: slim indicator bar only, no remote examples rendered', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
expect(host.querySelector('.showcase-gallery')).toBeTruthy();
|
||||
expect(host.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||
// Collapsed bar carries the count and the local preview thumbnail
|
||||
expect(host.querySelector('#galleryShowBtn')?.textContent).toContain('3');
|
||||
expect(host.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
|
||||
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
|
||||
// No thumbnails / media wrappers → no remote fetches until expanded
|
||||
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||
expect(host.querySelector('.media-wrapper')).toBeNull();
|
||||
// Import zone exists but stays collapsed
|
||||
const zone = host.querySelector('.gallery-import-zone');
|
||||
expect(zone?.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('expanded render shows toolbar, main viewer, thumbnails and nav controls', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
expect(host.querySelector('.gallery-indicator-bar')).toBeNull();
|
||||
expect(host.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
|
||||
expect(host.querySelector('.main-media-container .media-wrapper')).toBeTruthy();
|
||||
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(3);
|
||||
expect(host.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
|
||||
expect(host.querySelector('#galleryPrevBtn')).toBeTruthy();
|
||||
expect(host.querySelector('#galleryNextBtn')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('show/hide button toggles between indicator bar and gallery', async () => {
|
||||
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
|
||||
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||
|
||||
// Expand
|
||||
document.querySelector('#galleryShowBtn').click();
|
||||
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(3);
|
||||
expect(document.querySelector('.gallery-indicator-bar')).toBeNull();
|
||||
|
||||
// Collapse back to the indicator bar
|
||||
document.querySelector('#galleryShowBtn').click();
|
||||
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||
expect(document.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
|
||||
});
|
||||
|
||||
it('omits the import zone when the example images path is not configured', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
state.global.settings.example_images_path = '';
|
||||
|
||||
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
|
||||
expect(host.querySelector('.gallery-import-zone')).toBeNull();
|
||||
});
|
||||
|
||||
it('filters NSFW examples and reports the hidden count', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
state.settings.show_only_sfw = true;
|
||||
|
||||
const images = [
|
||||
IMAGES[0],
|
||||
{ url: 'https://image.civitai.com/abc/444.jpeg', width: 10, height: 10, nsfwLevel: 32 },
|
||||
];
|
||||
const html = renderShowcaseContent(images, [], '', true);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(1);
|
||||
expect(host.querySelector('.nsfw-filter-notification')).toBeTruthy();
|
||||
// Only one example left → no prev/next controls
|
||||
expect(host.querySelector('#galleryPrevBtn')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the import interface when there are no examples', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
|
||||
const html = renderShowcaseContent([], [], PREVIEW_URL);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
expect(host.querySelector('.example-import-area.empty')).toBeTruthy();
|
||||
expect(host.querySelector('#selectExampleFilesBtn')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the setup guidance when the path is missing and there are no examples', async () => {
|
||||
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||
state.global.settings.example_images_path = '';
|
||||
|
||||
const html = renderShowcaseContent([], []);
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
|
||||
expect(host.querySelector('.import-container--needs-setup')).toBeTruthy();
|
||||
expect(host.querySelector('#openExampleSettingsBtn')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switches the main display, position and active thumbnail when expanded', async () => {
|
||||
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||
|
||||
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
|
||||
|
||||
updateMainDisplay(2);
|
||||
|
||||
const activeThumb = document.querySelector('.gallery-thumb.active');
|
||||
expect(activeThumb?.dataset.index).toBe('2');
|
||||
expect(document.querySelector('#galleryPosition')?.textContent).toBe('3 / 3');
|
||||
const mainWrapper = document.querySelector('#mainMediaContainer .media-wrapper');
|
||||
expect(mainWrapper).toBeTruthy();
|
||||
// The third example is a video
|
||||
expect(mainWrapper.querySelector('video')).toBeTruthy();
|
||||
|
||||
// Wraps around past the end
|
||||
updateMainDisplay(3);
|
||||
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
|
||||
expect(document.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
|
||||
});
|
||||
|
||||
it('fits the main viewer to the active media aspect ratio', async () => {
|
||||
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||
|
||||
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
|
||||
|
||||
// First image is portrait 512x768 → aspect 0.667
|
||||
const container = document.getElementById('mainMediaContainer');
|
||||
expect(container.style.getPropertyValue('--media-aspect')).toBe(String(512 / 768));
|
||||
|
||||
// Second image is landscape 768x512 → aspect 1.5
|
||||
updateMainDisplay(1);
|
||||
expect(container.style.getPropertyValue('--media-aspect')).toBe('1.5');
|
||||
});
|
||||
|
||||
it('ignores main-display updates while collapsed', async () => {
|
||||
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||
|
||||
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
|
||||
|
||||
updateMainDisplay(1);
|
||||
|
||||
// Still collapsed: indicator bar untouched, no gallery rendered
|
||||
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
||||
|
||||
const { SHOWCASE_MODULE } = vi.hoisted(() => ({
|
||||
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
describe('Showcase listener metrics', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="modelModal">
|
||||
<div class="modal-content">
|
||||
<div class="showcase-section">
|
||||
<div class="carousel collapsed">
|
||||
<div class="scroll-indicator"></div>
|
||||
</div>
|
||||
<button class="back-to-top"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('tracks wheel/mutation/back-to-top listeners and resets after cleanup', async () => {
|
||||
const {
|
||||
setupShowcaseScroll,
|
||||
resetShowcaseListenerMetrics,
|
||||
showcaseListenerMetrics,
|
||||
} = await import(SHOWCASE_MODULE);
|
||||
|
||||
resetShowcaseListenerMetrics();
|
||||
|
||||
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
|
||||
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
|
||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
|
||||
|
||||
const cleanup = setupShowcaseScroll('modelModal');
|
||||
|
||||
expect(showcaseListenerMetrics.wheelListeners).toBe(1);
|
||||
expect(showcaseListenerMetrics.mutationObservers).toBe(1);
|
||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(1);
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
|
||||
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
|
||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
|
||||
});
|
||||
|
||||
it('remains stable after repeated setup/cleanup cycles', async () => {
|
||||
const {
|
||||
setupShowcaseScroll,
|
||||
resetShowcaseListenerMetrics,
|
||||
showcaseListenerMetrics,
|
||||
} = await import(SHOWCASE_MODULE);
|
||||
|
||||
resetShowcaseListenerMetrics();
|
||||
|
||||
const cleanupA = setupShowcaseScroll('modelModal');
|
||||
cleanupA();
|
||||
|
||||
const cleanupB = setupShowcaseScroll('modelModal');
|
||||
cleanupB();
|
||||
|
||||
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
|
||||
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
|
||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user