diff --git a/locales/de.json b/locales/de.json index 21d973a4..23916275 100644 --- a/locales/de.json +++ b/locales/de.json @@ -857,6 +857,11 @@ "sendCheckpoint": "Send to ComfyUI", "sendRecipe": "Send to ComfyUI" }, + "navigation": { + "label": "Rezeptnavigation", + "previousWithShortcut": "Vorheriges Rezept (←)", + "nextWithShortcut": "Nächstes Rezept (→)" + }, "workflow": { "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen", "created": "Rezept erfolgreich erstellt", "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", "preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download", "enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein", diff --git a/locales/en.json b/locales/en.json index e4b682f7..e715cdeb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -857,6 +857,11 @@ "sendCheckpoint": "Send to ComfyUI", "sendRecipe": "Send to ComfyUI" }, + "navigation": { + "label": "Recipe navigation", + "previousWithShortcut": "Previous recipe (\u2190)", + "nextWithShortcut": "Next recipe (\u2192)" + }, "workflow": { "sendWorkflow": "Send Workflow to ComfyUI", "sent": "Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "Missing required data to create recipe", "created": "Recipe created successfully", "noMissingLoras": "No missing LoRAs to download", + "noPreviousRecipe": "No previous recipe available", + "noNextRecipe": "No next recipe available", "missingLorasInfoFailed": "Failed to get information for missing LoRAs", "preparingForDownloadFailed": "Error preparing LoRAs for download", "enterLoraName": "Please enter a LoRA name or syntax", diff --git a/locales/es.json b/locales/es.json index a93dacec..0c340376 100644 --- a/locales/es.json +++ b/locales/es.json @@ -857,6 +857,11 @@ "sendCheckpoint": "Enviar a ComfyUI", "sendRecipe": "Enviar a ComfyUI" }, + "navigation": { + "label": "Navegación de recetas", + "previousWithShortcut": "Receta anterior (←)", + "nextWithShortcut": "Siguiente receta (→)" + }, "workflow": { "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "Faltan datos necesarios para crear la receta", "created": "Receta creada exitosamente", "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", "preparingForDownloadFailed": "Error preparando LoRAs para descarga", "enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis", diff --git a/locales/fr.json b/locales/fr.json index 6285939e..7fb97081 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -857,6 +857,11 @@ "sendCheckpoint": "Envoyer vers ComfyUI", "sendRecipe": "Envoyer vers ComfyUI" }, + "navigation": { + "label": "Navigation des recettes", + "previousWithShortcut": "Recette précédente (←)", + "nextWithShortcut": "Recette suivante (→)" + }, "workflow": { "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "Données requises manquantes pour créer le Recipe", "created": "Recipe créé avec succès", "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", "preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement", "enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA", diff --git a/locales/he.json b/locales/he.json index 2ecb064a..de13c4a8 100644 --- a/locales/he.json +++ b/locales/he.json @@ -857,6 +857,11 @@ "sendCheckpoint": "שלח ל-ComfyUI", "sendRecipe": "שלח ל-ComfyUI" }, + "navigation": { + "label": "ניווט מתכונים", + "previousWithShortcut": "המתכון הקודם (←)", + "nextWithShortcut": "המתכון הבא (→)" + }, "workflow": { "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "חסרים נתונים נדרשים ליצירת המתכון", "created": "המתכון נוצר בהצלחה", "noMissingLoras": "אין LoRAs חסרים להורדה", + "noPreviousRecipe": "אין מתכון קודם זמין", + "noNextRecipe": "אין מתכון נוסף זמין", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה", "enterLoraName": "אנא הזן שם LoRA או תחביר", diff --git a/locales/ja.json b/locales/ja.json index e905ed5e..8bd985d4 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -857,6 +857,11 @@ "sendCheckpoint": "ComfyUIへ送信", "sendRecipe": "ComfyUIへ送信" }, + "navigation": { + "label": "レシピナビゲーション", + "previousWithShortcut": "前のレシピ(←)", + "nextWithShortcut": "次のレシピ(→)" + }, "workflow": { "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "レシピ作成に必要なデータが不足しています", "created": "レシピを作成しました", "noMissingLoras": "ダウンロードする不足LoRAがありません", + "noPreviousRecipe": "前のレシピがありません", + "noNextRecipe": "次のレシピがありません", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました", "enterLoraName": "LoRA名または構文を入力してください", diff --git a/locales/ko.json b/locales/ko.json index 91fb81d9..acbbffa9 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -857,6 +857,11 @@ "sendCheckpoint": "ComfyUI로 보내기", "sendRecipe": "ComfyUI로 보내기" }, + "navigation": { + "label": "레시피 탐색", + "previousWithShortcut": "이전 레시피(←)", + "nextWithShortcut": "다음 레시피(→)" + }, "workflow": { "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "레시피 생성에 필요한 데이터가 없습니다", "created": "레시피가 생성되었습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", + "noPreviousRecipe": "이전 레시피가 없습니다", + "noNextRecipe": "다음 레시피가 없습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "preparingForDownloadFailed": "LoRA 다운로드 준비 오류", "enterLoraName": "LoRA 이름 또는 문법을 입력해주세요", diff --git a/locales/ru.json b/locales/ru.json index 95f7bbdb..690e39ef 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -857,6 +857,11 @@ "sendCheckpoint": "Отправить в ComfyUI", "sendRecipe": "Отправить в ComfyUI" }, + "navigation": { + "label": "Навигация по рецептам", + "previousWithShortcut": "Предыдущий рецепт (←)", + "nextWithShortcut": "Следующий рецепт (→)" + }, "workflow": { "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "Отсутствуют необходимые данные для создания рецепта", "created": "Рецепт успешно создан", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки", + "noPreviousRecipe": "Предыдущий рецепт отсутствует", + "noNextRecipe": "Следующий рецепт отсутствует", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки", "enterLoraName": "Пожалуйста, введите название LoRA или синтаксис", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 0ef30301..9657fdfa 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -857,6 +857,11 @@ "sendCheckpoint": "发送到 ComfyUI", "sendRecipe": "发送到 ComfyUI" }, + "navigation": { + "label": "配方导航", + "previousWithShortcut": "上一个配方(←)", + "nextWithShortcut": "下一个配方(→)" + }, "workflow": { "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "缺少创建配方所需的数据", "created": "配方创建成功", "noMissingLoras": "没有缺失的 LoRA 可下载", + "noPreviousRecipe": "没有上一个配方", + "noNextRecipe": "没有下一个配方", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "preparingForDownloadFailed": "准备下载 LoRA 时出错", "enterLoraName": "请输入 LoRA 名称或语法", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index c291ad0a..f803a274 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -857,6 +857,11 @@ "sendCheckpoint": "傳送到 ComfyUI", "sendRecipe": "傳送到 ComfyUI" }, + "navigation": { + "label": "配方導覽", + "previousWithShortcut": "上一個配方(←)", + "nextWithShortcut": "下一個配方(→)" + }, "workflow": { "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", "sent": "[TODO: Translate] Workflow sent to ComfyUI", @@ -1988,6 +1993,8 @@ "createMissingData": "缺少建立配方所需的資料", "created": "配方建立成功", "noMissingLoras": "無缺少的 LoRA 可下載", + "noPreviousRecipe": "沒有上一個配方", + "noNextRecipe": "沒有下一個配方", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤", "enterLoraName": "請輸入 LoRA 名稱或語法", diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index 4ebe5e16..022690ac 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -9,6 +9,21 @@ 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 { width: 100%; } diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index c17f5e52..55c6c3ce 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -55,6 +55,8 @@ class RecipeModal { constructor() { this.promptEditorState = {}; this.recipeHydrationRequestId = 0; + this.navigationKeyHandler = null; + this.navigationInProgress = false; this.resetLocalEditState(); this.init(); } @@ -120,6 +122,7 @@ class RecipeModal { this.setupCopyButtons(); this.setupStripLoraToggle(); this.setupPromptEditors(); + this.setupNavigationControls(); // Set up tooltip positioning handlers after DOM is ready document.addEventListener('DOMContentLoaded', () => { 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 setupTooltipPositioning() { document.addEventListener('mouseover', (event) => { @@ -303,7 +404,9 @@ class RecipeModal { this.syncHeaderActions(); // Show the modal - modalManager.showModal('recipeModal'); + modalManager.showModal('recipeModal', null, null, () => this.cleanupNavigationShortcuts()); + this.updateNavigationControls(); + this.setupNavigationShortcuts(); if (this.recipeId) { // Fire-and-forget: record this open for the "Recently Opened" diff --git a/templates/components/recipe_modal.html b/templates/components/recipe_modal.html index 269988a4..b130ea0b 100644 --- a/templates/components/recipe_modal.html +++ b/templates/components/recipe_modal.html @@ -3,7 +3,17 @@
-

Recipe Details

+
+

Recipe Details

+ +
+ +
+ +
+ +
+
+
+ + + + `; +} + +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'); + }); +}); \ No newline at end of file