feat(recipes): add prev/next navigation buttons and keyboard shortcuts to recipe modal

This commit is contained in:
Will Miao
2026-08-24 08:15:19 +08:00
parent c2360a35ad
commit 1a93b0eca2
15 changed files with 483 additions and 3 deletions
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "Send to ComfyUI", "sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "Send to ComfyUI" "sendRecipe": "Send to ComfyUI"
}, },
"navigation": {
"label": "Rezeptnavigation",
"previousWithShortcut": "Vorheriges Rezept (←)",
"nextWithShortcut": "Nächstes Rezept (→)"
},
"workflow": { "workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen", "createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt", "created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
"noNextRecipe": "Kein weiteres Rezept verfügbar",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download", "preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein", "enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "Send to ComfyUI", "sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "Send to ComfyUI" "sendRecipe": "Send to ComfyUI"
}, },
"navigation": {
"label": "Recipe navigation",
"previousWithShortcut": "Previous recipe (\u2190)",
"nextWithShortcut": "Next recipe (\u2192)"
},
"workflow": { "workflow": {
"sendWorkflow": "Send Workflow to ComfyUI", "sendWorkflow": "Send Workflow to ComfyUI",
"sent": "Workflow sent to ComfyUI", "sent": "Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "Missing required data to create recipe", "createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully", "created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download", "noMissingLoras": "No missing LoRAs to download",
"noPreviousRecipe": "No previous recipe available",
"noNextRecipe": "No next recipe available",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs", "missingLorasInfoFailed": "Failed to get information for missing LoRAs",
"preparingForDownloadFailed": "Error preparing LoRAs for download", "preparingForDownloadFailed": "Error preparing LoRAs for download",
"enterLoraName": "Please enter a LoRA name or syntax", "enterLoraName": "Please enter a LoRA name or syntax",
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "Enviar a ComfyUI", "sendCheckpoint": "Enviar a ComfyUI",
"sendRecipe": "Enviar a ComfyUI" "sendRecipe": "Enviar a ComfyUI"
}, },
"navigation": {
"label": "Navegación de recetas",
"previousWithShortcut": "Receta anterior (←)",
"nextWithShortcut": "Siguiente receta (→)"
},
"workflow": { "workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "Faltan datos necesarios para crear la receta", "createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente", "created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar", "noMissingLoras": "No hay LoRAs faltantes para descargar",
"noPreviousRecipe": "No hay receta anterior disponible",
"noNextRecipe": "No hay siguiente receta disponible",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", "missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
"preparingForDownloadFailed": "Error preparando LoRAs para descarga", "preparingForDownloadFailed": "Error preparando LoRAs para descarga",
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis", "enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "Envoyer vers ComfyUI", "sendCheckpoint": "Envoyer vers ComfyUI",
"sendRecipe": "Envoyer vers ComfyUI" "sendRecipe": "Envoyer vers ComfyUI"
}, },
"navigation": {
"label": "Navigation des recettes",
"previousWithShortcut": "Recette précédente (←)",
"nextWithShortcut": "Recette suivante (→)"
},
"workflow": { "workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "Données requises manquantes pour créer le Recipe", "createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès", "created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger", "noMissingLoras": "Aucun LoRA manquant à télécharger",
"noPreviousRecipe": "Aucune recette précédente",
"noNextRecipe": "Aucune recette suivante",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement", "preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA", "enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "שלח ל-ComfyUI", "sendCheckpoint": "שלח ל-ComfyUI",
"sendRecipe": "שלח ל-ComfyUI" "sendRecipe": "שלח ל-ComfyUI"
}, },
"navigation": {
"label": "ניווט מתכונים",
"previousWithShortcut": "המתכון הקודם (←)",
"nextWithShortcut": "המתכון הבא (→)"
},
"workflow": { "workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון", "createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה", "created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה", "noMissingLoras": "אין LoRAs חסרים להורדה",
"noPreviousRecipe": "אין מתכון קודם זמין",
"noNextRecipe": "אין מתכון נוסף זמין",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה", "preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
"enterLoraName": "אנא הזן שם LoRA או תחביר", "enterLoraName": "אנא הזן שם LoRA או תחביר",
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "ComfyUIへ送信", "sendCheckpoint": "ComfyUIへ送信",
"sendRecipe": "ComfyUIへ送信" "sendRecipe": "ComfyUIへ送信"
}, },
"navigation": {
"label": "レシピナビゲーション",
"previousWithShortcut": "前のレシピ(←)",
"nextWithShortcut": "次のレシピ(→)"
},
"workflow": { "workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "レシピ作成に必要なデータが不足しています", "createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました", "created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません", "noMissingLoras": "ダウンロードする不足LoRAがありません",
"noPreviousRecipe": "前のレシピがありません",
"noNextRecipe": "次のレシピがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました", "preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
"enterLoraName": "LoRA名または構文を入力してください", "enterLoraName": "LoRA名または構文を入力してください",
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "ComfyUI로 보내기", "sendCheckpoint": "ComfyUI로 보내기",
"sendRecipe": "ComfyUI로 보내기" "sendRecipe": "ComfyUI로 보내기"
}, },
"navigation": {
"label": "레시피 탐색",
"previousWithShortcut": "이전 레시피(←)",
"nextWithShortcut": "다음 레시피(→)"
},
"workflow": { "workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다", "createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다", "created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"noPreviousRecipe": "이전 레시피가 없습니다",
"noNextRecipe": "다음 레시피가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류", "preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요", "enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "Отправить в ComfyUI", "sendCheckpoint": "Отправить в ComfyUI",
"sendRecipe": "Отправить в ComfyUI" "sendRecipe": "Отправить в ComfyUI"
}, },
"navigation": {
"label": "Навигация по рецептам",
"previousWithShortcut": "Предыдущий рецепт (←)",
"nextWithShortcut": "Следующий рецепт (→)"
},
"workflow": { "workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "Отсутствуют необходимые данные для создания рецепта", "createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан", "created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
"noNextRecipe": "Следующий рецепт отсутствует",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки", "preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис", "enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "发送到 ComfyUI", "sendCheckpoint": "发送到 ComfyUI",
"sendRecipe": "发送到 ComfyUI" "sendRecipe": "发送到 ComfyUI"
}, },
"navigation": {
"label": "配方导航",
"previousWithShortcut": "上一个配方(←)",
"nextWithShortcut": "下一个配方(→)"
},
"workflow": { "workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "缺少创建配方所需的数据", "createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功", "created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载", "noMissingLoras": "没有缺失的 LoRA 可下载",
"noPreviousRecipe": "没有上一个配方",
"noNextRecipe": "没有下一个配方",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
"preparingForDownloadFailed": "准备下载 LoRA 时出错", "preparingForDownloadFailed": "准备下载 LoRA 时出错",
"enterLoraName": "请输入 LoRA 名称或语法", "enterLoraName": "请输入 LoRA 名称或语法",
+7
View File
@@ -857,6 +857,11 @@
"sendCheckpoint": "傳送到 ComfyUI", "sendCheckpoint": "傳送到 ComfyUI",
"sendRecipe": "傳送到 ComfyUI" "sendRecipe": "傳送到 ComfyUI"
}, },
"navigation": {
"label": "配方導覽",
"previousWithShortcut": "上一個配方(←)",
"nextWithShortcut": "下一個配方(→)"
},
"workflow": { "workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI",
@@ -1988,6 +1993,8 @@
"createMissingData": "缺少建立配方所需的資料", "createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功", "created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載", "noMissingLoras": "無缺少的 LoRA 可下載",
"noPreviousRecipe": "沒有上一個配方",
"noNextRecipe": "沒有下一個配方",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤", "preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
"enterLoraName": "請輸入 LoRA 名稱或語法", "enterLoraName": "請輸入 LoRA 名稱或語法",
+15
View File
@@ -9,6 +9,21 @@
position: relative; position: relative;
} }
/* Header row: title + nav controls. Padding reserves space for the
absolutely positioned nav buttons (see .modal-nav-controls in lora-modal.css). */
.recipe-modal-header-row {
box-sizing: border-box;
width: 100%;
position: relative;
padding-right: 152px;
}
/* 56px right offset keeps the nav buttons clear of the close (x) button,
which is absolutely positioned at the modal-content top-right corner. */
.recipe-modal-header-row .modal-nav-controls {
right: 56px;
}
#recipeTagsContainer { #recipeTagsContainer {
width: 100%; width: 100%;
} }
+104 -1
View File
@@ -55,6 +55,8 @@ class RecipeModal {
constructor() { constructor() {
this.promptEditorState = {}; this.promptEditorState = {};
this.recipeHydrationRequestId = 0; this.recipeHydrationRequestId = 0;
this.navigationKeyHandler = null;
this.navigationInProgress = false;
this.resetLocalEditState(); this.resetLocalEditState();
this.init(); this.init();
} }
@@ -120,6 +122,7 @@ class RecipeModal {
this.setupCopyButtons(); this.setupCopyButtons();
this.setupStripLoraToggle(); this.setupStripLoraToggle();
this.setupPromptEditors(); this.setupPromptEditors();
this.setupNavigationControls();
// Set up tooltip positioning handlers after DOM is ready // Set up tooltip positioning handlers after DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
this.setupTooltipPositioning(); this.setupTooltipPositioning();
@@ -164,6 +167,104 @@ class RecipeModal {
}); });
} }
setupNavigationControls() {
const prevBtn = document.getElementById('recipeNavPrevBtn');
const nextBtn = document.getElementById('recipeNavNextBtn');
if (prevBtn) {
prevBtn.addEventListener('click', () => this.handleDirectionalNavigation('prev'));
}
if (nextBtn) {
nextBtn.addEventListener('click', () => this.handleDirectionalNavigation('next'));
}
this.updateNavigationControls();
}
shouldIgnoreNavigationKey(event) {
const target = event.target;
if (!target) return false;
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
return target.isContentEditable || ['input', 'textarea', 'select', 'button'].includes(tagName);
}
updateNavigationControls() {
const modalElement = document.getElementById('recipeModal');
if (!modalElement) return;
const prevBtn = modalElement.querySelector('#recipeNavPrevBtn');
const nextBtn = modalElement.querySelector('#recipeNavNextBtn');
if (!prevBtn || !nextBtn) return;
const scroller = state.virtualScroller;
if (!scroller || typeof scroller.getNavigationState !== 'function') {
prevBtn.disabled = true;
nextBtn.disabled = true;
return;
}
const { hasPrev, hasNext } = scroller.getNavigationState(this.listFilePath || this.filePath || '');
prevBtn.disabled = this.navigationInProgress || !hasPrev;
nextBtn.disabled = this.navigationInProgress || !hasNext;
}
cleanupNavigationShortcuts() {
if (this.navigationKeyHandler) {
document.removeEventListener('keydown', this.navigationKeyHandler);
this.navigationKeyHandler = null;
}
this.navigationInProgress = false;
}
setupNavigationShortcuts() {
const modalElement = document.getElementById('recipeModal');
if (!modalElement) return;
this.cleanupNavigationShortcuts();
this.navigationKeyHandler = (event) => {
if (this.shouldIgnoreNavigationKey(event)) return;
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.handleDirectionalNavigation('prev');
} else if (event.key === 'ArrowRight') {
event.preventDefault();
this.handleDirectionalNavigation('next');
}
};
document.addEventListener('keydown', this.navigationKeyHandler);
}
async handleDirectionalNavigation(direction) {
if (this.navigationInProgress) return;
const scroller = state.virtualScroller;
const filePath = this.listFilePath || this.filePath || '';
if (!filePath || !scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') {
return;
}
this.navigationInProgress = true;
this.updateNavigationControls();
try {
const adjacent = await scroller.getAdjacentItemByFilePath(filePath, direction);
if (!adjacent || !adjacent.item) {
const toastKey = direction === 'prev' ? 'toast.recipes.noPreviousRecipe' : 'toast.recipes.noNextRecipe';
const toastFallback = direction === 'prev' ? 'No previous recipe available' : 'No next recipe available';
showToast(toastKey, {}, 'info', toastFallback);
return;
}
this.showRecipeDetails(adjacent.item);
} finally {
this.navigationInProgress = false;
this.updateNavigationControls();
}
}
// Add tooltip positioning handler to ensure correct positioning of fixed tooltips // Add tooltip positioning handler to ensure correct positioning of fixed tooltips
setupTooltipPositioning() { setupTooltipPositioning() {
document.addEventListener('mouseover', (event) => { document.addEventListener('mouseover', (event) => {
@@ -303,7 +404,9 @@ class RecipeModal {
this.syncHeaderActions(); this.syncHeaderActions();
// Show the modal // Show the modal
modalManager.showModal('recipeModal'); modalManager.showModal('recipeModal', null, null, () => this.cleanupNavigationShortcuts());
this.updateNavigationControls();
this.setupNavigationShortcuts();
if (this.recipeId) { if (this.recipeId) {
// Fire-and-forget: record this open for the "Recently Opened" // Fire-and-forget: record this open for the "Recently Opened"
+11 -1
View File
@@ -3,7 +3,17 @@
<button class="close" onclick="modalManager.closeModal('recipeModal')">&times;</button> <button class="close" onclick="modalManager.closeModal('recipeModal')">&times;</button>
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <div class="recipe-modal-header-row">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="modal-nav-controls" role="group" aria-label="{{ t('recipes.navigation.label') }}">
<button class="modal-nav-btn" id="recipeNavPrevBtn" title="{{ t('recipes.navigation.previousWithShortcut') }}" aria-label="{{ t('recipes.navigation.previousWithShortcut') }}" disabled>
<i class="fas fa-chevron-left" aria-hidden="true"></i>
</button>
<button class="modal-nav-btn" id="recipeNavNextBtn" title="{{ t('recipes.navigation.nextWithShortcut') }}" aria-label="{{ t('recipes.navigation.nextWithShortcut') }}" disabled>
<i class="fas fa-chevron-right" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- Header Actions: Send button is static; source URL button is appended dynamically in RecipeModal.js --> <!-- Header Actions: Send button is static; source URL button is appended dynamically in RecipeModal.js -->
<div class="recipe-header-actions" id="recipeHeaderActions"> <div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI"> <button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
@@ -330,7 +330,7 @@ describe('Interaction-level regression coverage', () => {
await new Promise((resolve) => setTimeout(resolve, 60)); await new Promise((resolve) => setTimeout(resolve, 60));
await flushAsyncTasks(); await flushAsyncTasks();
expect(modalManagerMock.showModal).toHaveBeenCalledWith('recipeModal'); expect(modalManagerMock.showModal).toHaveBeenCalledWith('recipeModal', null, null, expect.any(Function));
const editIcon = document.querySelector('#recipeModalTitle .edit-icon'); const editIcon = document.querySelector('#recipeModalTitle .edit-icon');
editIcon.dispatchEvent(new Event('click', { bubbles: true })); editIcon.dispatchEvent(new Event('click', { bubbles: true }));
@@ -0,0 +1,282 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
const loadingManagerStub = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
restoreProgressBar: vi.fn(),
};
const recipeItems = [
{ id: 'recipe-1', file_path: '/recipes/first.json', title: 'First Recipe', tags: [], loras: [] },
{ id: 'recipe-2', file_path: '/recipes/second.json', title: 'Second Recipe', tags: [], loras: [] },
{ id: 'recipe-3', file_path: '/recipes/third.json', title: 'Third Recipe', tags: [], loras: [] },
];
const virtualScrollerStub = {
updateSingleItem: vi.fn(),
getNavigationState: vi.fn((filePath) => {
const index = recipeItems.findIndex(item => item.file_path === filePath);
return {
index,
hasPrev: index > 0,
hasNext: index !== -1 && index < recipeItems.length - 1,
loadedItems: recipeItems.length,
totalItems: recipeItems.length,
};
}),
getAdjacentItemByFilePath: vi.fn(async (filePath, direction) => {
const currentIndex = recipeItems.findIndex(item => item.file_path === filePath);
if (currentIndex === -1) return null;
const targetIndex = currentIndex + (direction === 'prev' ? -1 : 1);
if (targetIndex < 0 || targetIndex >= recipeItems.length) return null;
return { item: recipeItems[targetIndex], index: targetIndex };
}),
};
const stateStub = {
global: { settings: {}, loadingManager: loadingManagerStub },
loadingManager: loadingManagerStub,
virtualScroller: virtualScrollerStub,
};
const modalManagerMock = {
showModal: vi.fn(),
closeModal: vi.fn(),
};
const sendRecipeWorkflowMock = vi.fn();
const fetchRecipeDetailsMock = vi.fn();
const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
sendModelPathToWorkflow: vi.fn(),
openCivitaiByMetadata: vi.fn(),
stripLoraTags: vi.fn((text) => text),
sendPromptToWorkflow: vi.fn(),
sendGenParamsToWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
getStorageItem: vi.fn(() => null),
setStorageItem: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
fetchRecipeDetails: fetchRecipeDetailsMock,
updateRecipeMetadata: updateRecipeMetadataMock,
sendRecipeWorkflow: sendRecipeWorkflowMock,
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings',
},
}));
function recipeModalFixture() {
return `
<div id="recipeModal" class="modal">
<div class="modal-content">
<header class="recipe-modal-header">
<div class="recipe-modal-header-row">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="modal-nav-controls">
<button class="modal-nav-btn" id="recipeNavPrevBtn" disabled>
<i class="fas fa-chevron-left"></i>
</button>
<button class="modal-nav-btn" id="recipeNavNextBtn" disabled>
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn"><i class="fas fa-paper-plane"></i></button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<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">
<div class="param-content" id="recipePrompt"></div>
<div class="param-editor" id="recipePromptEditor">
<textarea class="param-textarea" id="recipePromptInput"></textarea>
</div>
</div>
<div class="param-group info-item">
<div class="param-content" id="recipeNegativePrompt"></div>
<div class="param-editor" id="recipeNegativePromptEditor">
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
</div>
</div>
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-actions">
<span id="recipeLorasCount"></span>
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
</div>
</div>
</div>
</div>
`;
}
async function flushAsyncTasks() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
const createdModals = [];
async function createRecipeModal() {
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
const recipeModal = new RecipeModal();
createdModals.push(recipeModal);
return recipeModal;
}
describe('RecipeModal navigation', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = recipeModalFixture();
global.modalManager = modalManagerMock;
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({}),
}));
});
afterEach(() => {
createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts());
createdModals.length = 0;
document.body.innerHTML = '';
delete global.modalManager;
delete global.fetch;
});
it('enables prev/next buttons according to the scroller position', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItems[0]);
const prevBtn = document.getElementById('recipeNavPrevBtn');
const nextBtn = document.getElementById('recipeNavNextBtn');
expect(prevBtn.disabled).toBe(true);
expect(nextBtn.disabled).toBe(false);
});
it('disables the next button when the last recipe is shown', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItems[2]);
const prevBtn = document.getElementById('recipeNavPrevBtn');
const nextBtn = document.getElementById('recipeNavNextBtn');
expect(prevBtn.disabled).toBe(false);
expect(nextBtn.disabled).toBe(true);
});
it('navigates to the next recipe when the next button is clicked', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItems[0]);
document.getElementById('recipeNavNextBtn').click();
await flushAsyncTasks();
expect(virtualScrollerStub.getAdjacentItemByFilePath).toHaveBeenCalledWith('/recipes/first.json', 'next');
expect(recipeModal.currentRecipe.id).toBe('recipe-2');
expect(document.getElementById('recipeModalTitle').querySelector('.content-text').textContent).toBe('Second Recipe');
expect(document.getElementById('recipeNavPrevBtn').disabled).toBe(false);
});
it('navigates to the previous recipe when the prev button is clicked', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItems[1]);
document.getElementById('recipeNavPrevBtn').click();
await flushAsyncTasks();
expect(virtualScrollerStub.getAdjacentItemByFilePath).toHaveBeenCalledWith('/recipes/second.json', 'prev');
expect(recipeModal.currentRecipe.id).toBe('recipe-1');
});
it('navigates with the ArrowRight and ArrowLeft keyboard shortcuts', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItems[1]);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
await flushAsyncTasks();
expect(recipeModal.currentRecipe.id).toBe('recipe-1');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
await flushAsyncTasks();
expect(recipeModal.currentRecipe.id).toBe('recipe-2');
});
it('shows an info toast when navigating past the last recipe', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItems[2]);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.noNextRecipe', {}, 'info', 'No next recipe available');
});
it('ignores arrow keys while focus is inside an input or textarea', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItems[1]);
const input = document.getElementById('recipePromptInput');
input.focus();
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
await flushAsyncTasks();
expect(virtualScrollerStub.getAdjacentItemByFilePath).not.toHaveBeenCalled();
expect(recipeModal.currentRecipe.id).toBe('recipe-2');
});
it('removes the keyboard shortcut when the modal cleanup callback runs', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItems[1]);
const cleanupCallback = modalManagerMock.showModal.mock.calls[0][3];
expect(typeof cleanupCallback).toBe('function');
cleanupCallback();
expect(recipeModal.navigationKeyHandler).toBeNull();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
await flushAsyncTasks();
expect(recipeModal.currentRecipe.id).toBe('recipe-2');
});
});