mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-16 18:53:21 -03:00
feat(recipes): add toolbar toggle and settings preview for masonry layout
This commit is contained in:
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Nur Favoriten anzeigen",
|
||||
"action": "Favoriten"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Rezepte-Layout",
|
||||
"grid": "Raster-Layout",
|
||||
"masonry": "Masonry-Layout (Pinterest-Stil, behält das Seitenverhältnis des Bildes bei)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Show Favorites Only",
|
||||
"action": "Favorites"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Recipes Layout",
|
||||
"grid": "Grid layout",
|
||||
"masonry": "Masonry layout (Pinterest-style, preserves image aspect ratio)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Mostrar solo favoritos",
|
||||
"action": "Favoritos"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Diseño de recetas",
|
||||
"grid": "Vista de cuadrícula",
|
||||
"masonry": "Vista masonry (estilo Pinterest, conserva la proporción de aspecto de la imagen)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Afficher uniquement les favoris",
|
||||
"action": "Favoris"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Disposition des recettes",
|
||||
"grid": "Disposition en grille",
|
||||
"masonry": "Disposition masonry (style Pinterest, préserve le rapport d'aspect de l'image)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "הצג מועדפים בלבד",
|
||||
"action": "מועדפים"
|
||||
},
|
||||
"layout": {
|
||||
"title": "פריסת מתכונים",
|
||||
"grid": "פריסת רשת",
|
||||
"masonry": "פריסת Masonry (בסגנון Pinterest, שומרת על יחס הגובה-רוחב של התמונה)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "お気に入りのみ表示",
|
||||
"action": "お気に入り"
|
||||
},
|
||||
"layout": {
|
||||
"title": "レシピのレイアウト",
|
||||
"grid": "グリッドレイアウト",
|
||||
"masonry": "メイソンリーレイアウト(Pinterest スタイル、画像のアスペクト比を保持)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "즐겨찾기만 표시",
|
||||
"action": "즐겨찾기"
|
||||
},
|
||||
"layout": {
|
||||
"title": "레시피 레이아웃",
|
||||
"grid": "그리드 레이아웃",
|
||||
"masonry": "메이슨리 레이아웃 (Pinterest 스타일, 이미지 종횡비 유지)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "Только избранные",
|
||||
"action": "Избранное"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Макет рецептов",
|
||||
"grid": "Макет сеткой",
|
||||
"masonry": "Masonry-макет (в стиле Pinterest, сохраняет пропорции изображения)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "仅显示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"layout": {
|
||||
"title": "配方布局",
|
||||
"grid": "网格布局",
|
||||
"masonry": "瀑布流布局(Pinterest 风格,保留图片原始宽高比)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -937,6 +937,11 @@
|
||||
"favorites": {
|
||||
"title": "僅顯示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"layout": {
|
||||
"title": "配方版面",
|
||||
"grid": "網格版面",
|
||||
"masonry": "瀑布流版面(Pinterest 風格,保留圖片原始寬高比)"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
|
||||
@@ -911,6 +911,93 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Recipes layout segmented control with visual previews */
|
||||
.layout-options-control {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.layout-options {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout-option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.layout-option:hover,
|
||||
.layout-option:focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.layout-option.active {
|
||||
border-color: var(--lora-accent);
|
||||
background-color: rgba(from var(--lora-accent) r g b / 0.12);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.layout-option-label {
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layout-option-preview {
|
||||
width: 72px;
|
||||
height: 44px;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.layout-option-preview span {
|
||||
background: currentColor;
|
||||
opacity: 0.4;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.layout-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.layout-preview-masonry {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.layout-preview-masonry span {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-preview-masonry span:nth-child(2) {
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.layout-preview-masonry span:nth-child(3) {
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
/* Range Slider Control */
|
||||
.range-control {
|
||||
width: 100%;
|
||||
|
||||
@@ -168,6 +168,34 @@
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Recipes layout toggle (grid / masonry) — segmented control in the toolbar */
|
||||
.layout-toggle-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.layout-toggle-group .layout-toggle-btn {
|
||||
min-width: 36px;
|
||||
width: 36px;
|
||||
padding: 4px 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.layout-toggle-group .layout-toggle-btn:first-child {
|
||||
border-radius: var(--border-radius-xs) 0 0 var(--border-radius-xs);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.layout-toggle-group .layout-toggle-btn:last-child {
|
||||
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
|
||||
}
|
||||
|
||||
.layout-toggle-group .layout-toggle-btn:hover,
|
||||
.layout-toggle-group .layout-toggle-btn:focus-visible {
|
||||
transform: none;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* Keyboard shortcut indicator styling */
|
||||
.shortcut-key {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1017,11 +1017,8 @@ 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 recipes layout setting (segmented control active state)
|
||||
this.updateRecipesLayoutControls(state.global.settings.recipes_layout || 'grid');
|
||||
|
||||
// Set card info display setting
|
||||
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
|
||||
@@ -2294,19 +2291,18 @@ export class SettingsManager {
|
||||
: element.value;
|
||||
|
||||
try {
|
||||
// Recipes layout has its own shared entry point used by both the
|
||||
// settings modal segmented control and the recipes page toolbar toggle
|
||||
if (settingKey === 'recipes_layout') {
|
||||
return this.saveRecipesLayout(element.value);
|
||||
}
|
||||
|
||||
// Update frontend state with mapped keys
|
||||
await this.saveSetting(settingKey, value);
|
||||
|
||||
// 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();
|
||||
@@ -2334,6 +2330,47 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
|
||||
* Shared entry point for the settings modal segmented control and the
|
||||
* recipes page toolbar toggle; both stay in sync via
|
||||
* updateRecipesLayoutControls().
|
||||
*/
|
||||
async saveRecipesLayout(value) {
|
||||
if (value !== 'grid' && value !== 'masonry') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update frontend state with mapped keys
|
||||
await this.saveSetting('recipes_layout', value);
|
||||
|
||||
// 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
|
||||
window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed'));
|
||||
|
||||
this.updateRecipesLayoutControls(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the active state of every recipes layout control
|
||||
* (settings modal segmented control and recipes page toolbar toggle).
|
||||
*/
|
||||
updateRecipesLayoutControls(value) {
|
||||
document.querySelectorAll('[data-recipes-layout]').forEach((control) => {
|
||||
const active = control.dataset.recipesLayout === value;
|
||||
control.classList.toggle('active', active);
|
||||
if (control.hasAttribute('aria-pressed')) {
|
||||
control.setAttribute('aria-pressed', String(active));
|
||||
}
|
||||
if (control.hasAttribute('aria-checked')) {
|
||||
control.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveRangeSetting(elementId, displayId, settingKey) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
@@ -282,6 +282,30 @@ class RecipeManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Layout toggle (grid / masonry) — shares the recipes_layout setting with
|
||||
// the settings modal segmented control; active states stay in sync via
|
||||
// settingsManager.updateRecipesLayoutControls() after each save
|
||||
const layoutToggleBtns = document.querySelectorAll('.layout-toggle-btn');
|
||||
if (layoutToggleBtns.length) {
|
||||
const currentLayout = state.global.settings?.recipes_layout || 'grid';
|
||||
layoutToggleBtns.forEach((btn) => {
|
||||
const isActive = btn.dataset.recipesLayout === currentLayout;
|
||||
btn.classList.toggle('active', isActive);
|
||||
btn.setAttribute('aria-pressed', String(isActive));
|
||||
btn.addEventListener('click', async () => {
|
||||
const layout = btn.dataset.recipesLayout;
|
||||
if ((state.global.settings?.recipes_layout || 'grid') === layout) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.settingsManager?.saveRecipesLayout(layout);
|
||||
} catch (error) {
|
||||
console.error('Failed to switch recipes layout:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -134,6 +134,16 @@
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
{% if page_id == 'recipes' %}
|
||||
<div class="control-group layout-toggle-group" role="group" aria-label="{{ t('recipes.controls.layout.title') }}" title="{{ t('recipes.controls.layout.title') }}">
|
||||
<button type="button" class="layout-toggle-btn" data-recipes-layout="grid" aria-pressed="false" title="{{ t('recipes.controls.layout.grid') }}" aria-label="{{ t('recipes.controls.layout.grid') }}">
|
||||
<i class="fas fa-th-large" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="layout-toggle-btn" data-recipes-layout="masonry" aria-pressed="false" title="{{ t('recipes.controls.layout.masonry') }}" aria-label="{{ t('recipes.controls.layout.masonry') }}">
|
||||
<i class="fas fa-columns" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="control-group doctor-control-group">
|
||||
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
|
||||
@@ -629,16 +629,22 @@
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="recipesLayout">
|
||||
<label id="recipesLayoutLabel">
|
||||
{{ t('settings.layoutSettings.recipesLayout') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')">
|
||||
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option>
|
||||
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option>
|
||||
</select>
|
||||
<div class="setting-control layout-options-control">
|
||||
<div id="recipesLayoutOptions" class="layout-options" role="radiogroup" aria-label="{{ t('settings.layoutSettings.recipesLayout') }}" aria-labelledby="recipesLayoutLabel">
|
||||
<button type="button" class="layout-option" data-recipes-layout="grid" onclick="settingsManager.saveRecipesLayout('grid')" role="radio" aria-checked="true">
|
||||
<span class="layout-option-preview layout-preview-grid" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
|
||||
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</span>
|
||||
</button>
|
||||
<button type="button" class="layout-option" data-recipes-layout="masonry" onclick="settingsManager.saveRecipesLayout('masonry')" role="radio" aria-checked="false">
|
||||
<span class="layout-option-preview layout-preview-masonry" aria-hidden="true"><span></span><span></span><span></span></span>
|
||||
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -530,4 +530,49 @@ describe('SettingsManager recipes layout switch', () => {
|
||||
dispatchSpy.mockRestore();
|
||||
delete state.virtualScroller;
|
||||
});
|
||||
|
||||
it('saveRecipesLayout persists, dispatches the layout event, and syncs controls', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
const gridBtn = document.createElement('button');
|
||||
gridBtn.dataset.recipesLayout = 'grid';
|
||||
gridBtn.setAttribute('aria-pressed', 'false');
|
||||
const masonryBtn = document.createElement('button');
|
||||
masonryBtn.dataset.recipesLayout = 'masonry';
|
||||
masonryBtn.setAttribute('aria-pressed', 'false');
|
||||
masonryBtn.setAttribute('role', 'radio');
|
||||
masonryBtn.setAttribute('aria-checked', 'false');
|
||||
document.body.appendChild(gridBtn);
|
||||
document.body.appendChild(masonryBtn);
|
||||
|
||||
const calculateLayout = vi.fn();
|
||||
state.virtualScroller = { calculateLayout };
|
||||
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
await manager.saveRecipesLayout('masonry');
|
||||
|
||||
expect(state.global.settings.recipes_layout).toBe('masonry');
|
||||
expect(masonryBtn.classList.contains('active')).toBe(true);
|
||||
expect(masonryBtn.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(masonryBtn.getAttribute('aria-checked')).toBe('true');
|
||||
expect(gridBtn.classList.contains('active')).toBe(false);
|
||||
expect(gridBtn.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
it('ignores invalid recipes layout values', async () => {
|
||||
const manager = createManager();
|
||||
await manager.saveRecipesLayout('bogus');
|
||||
expect(state.global.settings.recipes_layout).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,6 +163,7 @@ describe('RecipeManager', () => {
|
||||
afterEach(() => {
|
||||
delete window.recipeManager;
|
||||
delete window.importManager;
|
||||
delete window.settingsManager;
|
||||
});
|
||||
|
||||
it('initializes page controls, restores filters, and wires sort interactions', async () => {
|
||||
@@ -227,6 +228,38 @@ describe('RecipeManager', () => {
|
||||
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('wires the layout toggle and reflects the saved recipes layout setting', async () => {
|
||||
const gridBtn = document.createElement('button');
|
||||
gridBtn.className = 'layout-toggle-btn';
|
||||
gridBtn.dataset.recipesLayout = 'grid';
|
||||
gridBtn.setAttribute('aria-pressed', 'false');
|
||||
const masonryBtn = document.createElement('button');
|
||||
masonryBtn.className = 'layout-toggle-btn';
|
||||
masonryBtn.dataset.recipesLayout = 'masonry';
|
||||
masonryBtn.setAttribute('aria-pressed', 'false');
|
||||
document.body.appendChild(gridBtn);
|
||||
document.body.appendChild(masonryBtn);
|
||||
|
||||
const saveRecipesLayoutMock = vi.fn().mockResolvedValue();
|
||||
window.settingsManager = { saveRecipesLayout: saveRecipesLayoutMock };
|
||||
|
||||
const manager = new RecipeManager();
|
||||
await manager.initialize();
|
||||
|
||||
// Initial state follows the saved setting (default grid)
|
||||
expect(gridBtn.classList.contains('active')).toBe(true);
|
||||
expect(gridBtn.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(masonryBtn.classList.contains('active')).toBe(false);
|
||||
|
||||
// Clicking the inactive option saves the new layout
|
||||
masonryBtn.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(saveRecipesLayoutMock).toHaveBeenCalledWith('masonry');
|
||||
|
||||
// Clicking the already-active option is a no-op
|
||||
gridBtn.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(saveRecipesLayoutMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips loading when duplicates mode is active and refreshes otherwise', async () => {
|
||||
const manager = new RecipeManager();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user