diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js index a2360c06..c235b889 100644 --- a/static/js/components/DuplicatesManager.js +++ b/static/js/components/DuplicatesManager.js @@ -2,6 +2,7 @@ import { showToast } from '../utils/uiHelpers.js'; import { RecipeCard } from './RecipeCard.js'; import { state, getCurrentPageState } from '../state/index.js'; +import { recreateVirtualScroll } from '../utils/infiniteScroll.js'; export class DuplicatesManager { constructor(recipeManager) { @@ -96,6 +97,12 @@ export class DuplicatesManager { // Re-enable virtual scrolling state.virtualScroller.enable(); + + // Apply a layout switch that was deferred while duplicates mode was active + if (state.pendingLayoutRecreate) { + state.pendingLayoutRecreate = false; + recreateVirtualScroll('recipes'); + } } renderDuplicateGroups() { diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index bc3c19fd..70b11958 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1017,6 +1017,12 @@ export class SettingsManager { displayDensitySelect.value = state.global.settings.display_density || 'default'; } + // Set recipes layout setting + const recipesLayoutSelect = document.getElementById('recipesLayout'); + if (recipesLayoutSelect) { + recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid'; + } + // Set card info display setting const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay'); if (cardInfoDisplaySelect) { @@ -2288,6 +2294,13 @@ export class SettingsManager { // Apply frontend settings immediately this.applyFrontendSettings(); + // Dispatch layout change event; the scroller instance is about to be rebuilt, + // so calculateLayout() must NOT run on the old instance here + if (settingKey === 'recipes_layout') { + window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed')); + return; + } + // Recalculate layout when display density changes if (settingKey === 'display_density' && state.virtualScroller) { state.virtualScroller.calculateLayout(); diff --git a/static/js/recipes.js b/static/js/recipes.js index b36bd2ea..47bec967 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -7,7 +7,7 @@ import { state, getCurrentPageState } from './state/index.js'; import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { DuplicatesManager } from './components/DuplicatesManager.js'; -import { refreshVirtualScroll } from './utils/infiniteScroll.js'; +import { refreshVirtualScroll, recreateVirtualScroll } from './utils/infiniteScroll.js'; import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js'; import { sidebarManager } from './components/SidebarManager.js'; import { initSortDropdown } from './components/controls/SortDropdown.js'; @@ -272,6 +272,20 @@ class RecipeManager { }); } + // Rebuild the scroller on layout switch; in duplicates mode defer until + // exitDuplicateMode re-enables the scroller (direct recreation would dispose + // the old instance while initializeVirtualScroll skips duplicates mode) + window.addEventListener('lm:recipes-layout-changed', () => { + const pageState = getCurrentPageState(); + if (pageState.duplicatesMode) { + state.pendingLayoutRecreate = true; + return; + } + if (typeof recreateVirtualScroll === 'function') { + recreateVirtualScroll('recipes'); + } + }); + // Initialize dropdown functionality for refresh button this.initDropdowns(); } diff --git a/tests/frontend/managers/settingsManager.library.test.js b/tests/frontend/managers/settingsManager.library.test.js index 714a7ec3..60cffae7 100644 --- a/tests/frontend/managers/settingsManager.library.test.js +++ b/tests/frontend/managers/settingsManager.library.test.js @@ -501,3 +501,33 @@ describe('SettingsManager library controls', () => { expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none'); }); }); + +describe('SettingsManager recipes layout switch', () => { + it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => { + const manager = createManager(); + const select = document.createElement('select'); + select.id = 'recipesLayout'; + const option = document.createElement('option'); + option.value = 'masonry'; + select.appendChild(option); + select.value = 'masonry'; + document.body.appendChild(select); + + const calculateLayout = vi.fn(); + state.virtualScroller = { calculateLayout }; + + const dispatchSpy = vi.spyOn(window, 'dispatchEvent'); + + await manager.saveSelectSetting('recipesLayout', 'recipes_layout'); + + const layoutEvent = dispatchSpy.mock.calls + .map(([event]) => event) + .find(event => event.type === 'lm:recipes-layout-changed'); + expect(layoutEvent).toBeInstanceOf(CustomEvent); + expect(calculateLayout).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + + dispatchSpy.mockRestore(); + delete state.virtualScroller; + }); +});