Compare commits

...

4 Commits

Author SHA1 Message Date
Will Miao 6b41c3bbb4 fix(tests): deflake recipe modal resource item tests by disposing modal instances
RecipeModal instances keep fire-and-forget async chains (hydration
re-renders, mark-hash-invalid re-renders, 500ms reconnect/restore
re-renders) and deferred DOM wiring timers alive across tests. On slow
CI runners these land in the next test's window and overwrite or re-wire
the shared document.body with stale content and stale instance handlers,
failing a different test on every run.

Add a tracked-timer helper and a dispose() teardown hook to RecipeModal:
pending deferred work is cancelled, in-flight async chains become no-ops
after disposal, and the global click listener is detached. The test
afterEach now disposes every modal instance, making the file hermetic.
2026-09-02 12:40:37 +08:00
Will Miao b37238d790 fix(ui): disable modal backdrop blur under software rendering (#1092)
With hardware acceleration disabled, Chrome rasterizes in software and a
full-viewport backdrop-filter forces a per-frame CPU blur over everything
behind the modal, freezing the whole browser.

Detect software rendering via the unmasked WebGL renderer string at app
startup and drop the backdrop blur in that case. Also route the download
modal's sticky toolbar through the shared --modal-backdrop-blur variable
instead of a hardcoded blur(8px).
2026-09-02 12:24:32 +08:00
Will Miao bc33e32c6f feat(showcase): add wheel, swipe and keyboard navigation to the example gallery
- Wheel on the main viewer: horizontal deltas always switch examples;
  vertical deltas switch only at the modal scroll boundary, then stay in a
  sticky session (down = next, up = prev) until the pointer leaves the area
- Touch/pen horizontal swipe switches examples; the synthesized click after
  a swipe is swallowed so the media viewer does not open
- '[' / ']' switch examples while the gallery is expanded; ArrowLeft/Right
  stay reserved for model-level navigation
- Direction-aware slide transition on every switch for visual feedback
  (respects prefers-reduced-motion)
2026-09-02 11:52:59 +08:00
Will Miao 49704d801c fix: show lora info regardless of toggle state 2026-09-02 11:38:30 +08:00
21 changed files with 673 additions and 41 deletions
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Beispiele anzeigen ({count})",
"hideExamples": "Beispiele ausblenden",
"addExamples": "Beispiele hinzufügen",
"previousExample": "Vorheriges Beispiel",
"nextExample": "Nächstes Beispiel",
"previousExample": "Vorheriges Beispiel ([)",
"nextExample": "Nächstes Beispiel (])",
"noExamples": "Keine Beispielbilder verfügbar",
"addMoreExamples": "Weitere Beispiele hinzufügen",
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Show examples ({count})",
"hideExamples": "Hide examples",
"addExamples": "Add examples",
"previousExample": "Previous example",
"nextExample": "Next example",
"previousExample": "Previous example ([)",
"nextExample": "Next example (])",
"noExamples": "No example images available",
"addMoreExamples": "Add more examples",
"dragDrop": "Drag & drop images or videos here",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Mostrar ejemplos ({count})",
"hideExamples": "Ocultar ejemplos",
"addExamples": "Añadir ejemplos",
"previousExample": "Ejemplo anterior",
"nextExample": "Ejemplo siguiente",
"previousExample": "Ejemplo anterior ([)",
"nextExample": "Ejemplo siguiente (])",
"noExamples": "No hay imágenes de ejemplo disponibles",
"addMoreExamples": "Añadir más ejemplos",
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Afficher les exemples ({count})",
"hideExamples": "Masquer les exemples",
"addExamples": "Ajouter des exemples",
"previousExample": "Exemple précédent",
"nextExample": "Exemple suivant",
"previousExample": "Exemple précédent ([)",
"nextExample": "Exemple suivant (])",
"noExamples": "Aucune image d'exemple disponible",
"addMoreExamples": "Ajouter d'autres exemples",
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "הצג דוגמאות ({count})",
"hideExamples": "הסתר דוגמאות",
"addExamples": "הוסף דוגמאות",
"previousExample": "דוגמה קודמת",
"nextExample": "דוגמה הבאה",
"previousExample": "דוגמה קודמת ([)",
"nextExample": "דוגמה הבאה (])",
"noExamples": "אין תמונות דוגמה זמינות",
"addMoreExamples": "הוסף עוד דוגמאות",
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "例を表示({count}",
"hideExamples": "例を非表示",
"addExamples": "例を追加",
"previousExample": "前の例",
"nextExample": "次の例",
"previousExample": "前の例[",
"nextExample": "次の例]",
"noExamples": "利用可能な例画像がありません",
"addMoreExamples": "さらに例を追加",
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "예시 보기 ({count})",
"hideExamples": "예시 숨기기",
"addExamples": "예시 추가",
"previousExample": "이전 예시",
"nextExample": "다음 예시",
"previousExample": "이전 예시([)",
"nextExample": "다음 예시(])",
"noExamples": "사용 가능한 예시 이미지가 없습니다",
"addMoreExamples": "예시 더 추가",
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Показать примеры ({count})",
"hideExamples": "Скрыть примеры",
"addExamples": "Добавить примеры",
"previousExample": "Предыдущий пример",
"nextExample": "Следующий пример",
"previousExample": "Предыдущий пример ([)",
"nextExample": "Следующий пример (])",
"noExamples": "Примеры изображений недоступны",
"addMoreExamples": "Добавить ещё примеры",
"dragDrop": "Перетащите изображения или видео сюда",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "显示示例({count}",
"hideExamples": "隐藏示例",
"addExamples": "添加示例",
"previousExample": "上一个示例",
"nextExample": "下一个示例",
"previousExample": "上一个示例[",
"nextExample": "下一个示例]",
"noExamples": "暂无示例图片",
"addMoreExamples": "添加更多示例",
"dragDrop": "将图片或视频拖放到此处",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "顯示範例({count}",
"hideExamples": "隱藏範例",
"addExamples": "新增範例",
"previousExample": "上一個範例",
"nextExample": "下一個範例",
"previousExample": "上一個範例[",
"nextExample": "下一個範例]",
"noExamples": "沒有可用的範例圖片",
"addMoreExamples": "新增更多範例",
"dragDrop": "拖放圖片或影片到此處",
@@ -115,6 +115,9 @@
position: relative;
overflow: hidden;
border-radius: var(--border-radius-sm);
/* Horizontal touch pans are claimed for swipe navigation (ShowcaseView);
vertical pans still scroll the modal */
touch-action: pan-y;
}
.main-media-container {
@@ -134,6 +137,32 @@
margin-bottom: 0;
}
/* Direction-aware slide on example switches (set by updateMainDisplay) */
.main-media-container.slide-from-right .media-wrapper {
animation: gallery-slide-from-right 0.25s ease;
}
.main-media-container.slide-from-left .media-wrapper {
animation: gallery-slide-from-left 0.25s ease;
}
@keyframes gallery-slide-from-right {
from { transform: translateX(32px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes gallery-slide-from-left {
from { transform: translateX(-32px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.main-media-container.slide-from-right .media-wrapper,
.main-media-container.slide-from-left .media-wrapper {
animation: none;
}
}
.main-media-container .media-wrapper img,
.main-media-container .media-wrapper video {
position: absolute;
+10
View File
@@ -13,6 +13,16 @@
overflow: auto; /* Change from hidden to auto to allow scrolling */
}
/* Software-rendering fallback (set by applyModalBackdropBlurPolicy): a
full-viewport backdrop-filter forces per-frame CPU rasterization of
everything behind the modal and freezes the browser (issue #1092) */
html.no-modal-backdrop-blur .modal,
html.no-modal-backdrop-blur .delete-modal,
html.no-modal-backdrop-blur .batch-preview-select-all {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Prevent body scroll when modal is open */
body.modal-open {
position: fixed;
@@ -921,8 +921,8 @@
position: sticky;
top: 0;
z-index: 1;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
}
.batch-preview-select-all input[type="checkbox"] {
+76 -12
View File
@@ -128,6 +128,9 @@ class RecipeModal {
this.recipeHydrationRequestId = 0;
this.navigationKeyHandler = null;
this.navigationInProgress = false;
this._disposed = false;
this._deferredTimerIds = new Set();
this._documentClickHandler = null;
this.resetLocalEditState();
this.init();
}
@@ -197,7 +200,7 @@ class RecipeModal {
this.setupDeleteControl();
// Set up document click handler to close edit fields
document.addEventListener('click', (event) => {
this._documentClickHandler = (event) => {
const recipeModal = document.getElementById('recipeModal');
if (recipeModal && recipeModal.style.display !== 'none') {
const mediaEl = event.target.closest('.recipe-preview-media');
@@ -235,7 +238,8 @@ class RecipeModal {
this.hideReconnectInput(container);
}
});
});
};
document.addEventListener('click', this._documentClickHandler);
}
setupNavigationControls() {
@@ -299,6 +303,39 @@ class RecipeModal {
this._destroyAllReconnectComboboxes();
}
/**
* Run a callback on a tracked timer so it can be cancelled when the
* modal is disposed. Used for render/wiring work that must never touch
* the DOM after the modal has been torn down (test teardown, close).
*/
_scheduleDeferred(fn, delay) {
const timerId = setTimeout(() => {
this._deferredTimerIds.delete(timerId);
fn();
}, delay);
this._deferredTimerIds.add(timerId);
return timerId;
}
/**
* Tear the modal down: mark it disposed (in-flight async chains and any
* later render/wiring become no-ops), cancel pending deferred work, and
* detach global listeners. Safe to call multiple times.
*/
dispose() {
if (this._disposed) {
return;
}
this._disposed = true;
this._deferredTimerIds.forEach(timerId => clearTimeout(timerId));
this._deferredTimerIds.clear();
if (this._documentClickHandler) {
document.removeEventListener('click', this._documentClickHandler);
this._documentClickHandler = null;
}
this.cleanupNavigationShortcuts();
}
setupNavigationShortcuts() {
const modalElement = document.getElementById('recipeModal');
if (!modalElement) return;
@@ -353,6 +390,9 @@ class RecipeModal {
}
showRecipeDetails(recipe) {
if (this._disposed) {
return;
}
const hydratedRecipe = recipe || {};
this.resetLocalEditState();
// Store the full recipe for editing
@@ -443,7 +483,7 @@ class RecipeModal {
// Delay binding slightly so modal layout is stable, but skip if this render was torn down.
const sourceUrlContainerRef = sourceUrlContainer;
const sourceUrlEditorRef = sourceUrlEditor;
setTimeout(() => {
this._scheduleDeferred(() => {
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
return;
}
@@ -556,7 +596,7 @@ class RecipeModal {
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
try {
const fullRecipe = await fetchRecipeDetails(recipeId);
if (requestId !== this.recipeHydrationRequestId || !fullRecipe) {
if (this._disposed || requestId !== this.recipeHydrationRequestId || !fullRecipe) {
return;
}
@@ -952,6 +992,9 @@ class RecipeModal {
}
syncResourcesSection(recipe = {}) {
if (this._disposed) {
return;
}
const checkpointContainer = document.getElementById('recipeCheckpoint');
const resourceDivider = document.getElementById('recipeResourceDivider');
const lorasListElement = document.getElementById('recipeLorasList');
@@ -1016,7 +1059,7 @@ class RecipeModal {
missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal());
}
setTimeout(() => {
this._scheduleDeferred(() => {
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
@@ -1157,7 +1200,7 @@ class RecipeModal {
`;
}).join('');
setTimeout(() => {
this._scheduleDeferred(() => {
this.setupReconnectButtons();
this.setupLoraItemActions();
this.setupLoraItemsClickable();
@@ -2113,13 +2156,13 @@ class RecipeModal {
try {
const suggestions = await this._fetchReconnectSuggestions(loraIndex);
// Stale guard: panel closed or another item opened while fetching
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
if (this._disposed || token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
return;
}
this._renderReconnectSuggestions(container, suggestions, loraIndex);
} catch (error) {
console.error('Error fetching reconnect suggestions:', error);
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
if (this._disposed || token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
return;
}
this._renderReconnectSuggestions(container, [], loraIndex);
@@ -2231,6 +2274,9 @@ class RecipeModal {
});
const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) {
// Hide the reconnect input
@@ -2256,7 +2302,7 @@ class RecipeModal {
}
// Refresh modal to show updated content
setTimeout(() => {
this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
@@ -2290,6 +2336,9 @@ class RecipeModal {
});
const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) {
// Swap the entry back to its pre-reconnect state
@@ -2297,7 +2346,7 @@ class RecipeModal {
showToast('toast.recipes.loraRestored', {}, 'success');
setTimeout(() => {
this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
@@ -2342,6 +2391,9 @@ class RecipeModal {
});
const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) {
// Hide the reconnect input
@@ -2367,7 +2419,7 @@ class RecipeModal {
}
// Refresh modal to show updated content
setTimeout(() => {
this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
@@ -2400,6 +2452,9 @@ class RecipeModal {
});
const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) {
// Swap the entry back to its pre-reconnect state
@@ -2407,7 +2462,7 @@ class RecipeModal {
showToast('toast.recipes.checkpointRestored', {}, 'success');
setTimeout(() => {
this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
@@ -2442,6 +2497,9 @@ class RecipeModal {
recipe_id: recipeId,
}),
});
if (this._disposed) {
return;
}
if (this.currentRecipe?.checkpoint) {
this.currentRecipe.checkpoint.hashInvalid = true;
this.syncResourcesSection(this.currentRecipe);
@@ -2975,6 +3033,9 @@ class RecipeModal {
return;
}
const updated = await fetchRecipeDetails(recipeId);
if (this._disposed) {
return;
}
if (!updated) {
return;
}
@@ -3083,6 +3144,9 @@ class RecipeModal {
lora_index: loraIndex,
}),
});
if (this._disposed) {
return;
}
if (this.currentRecipe?.loras?.[loraIndex]) {
this.currentRecipe.loras[loraIndex].hashInvalid = true;
this.syncResourcesSection(this.currentRecipe);
@@ -23,7 +23,7 @@ import {
import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js';
import { openMediaViewer, isMediaViewerOpen } from '../MediaViewer.js';
import { escapeAttribute } from '../utils.js';
/**
@@ -231,10 +231,10 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)}
${renderPositionBadge(positionText)}
</div>
${showNav ? `<button class="gallery-nav prev" id="galleryPrevBtn" title="${translate('modals.model.showcase.previousExample', {}, 'Previous example')}">
${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')}">
<button class="gallery-nav next" id="galleryNextBtn" title="${translate('modals.model.showcase.nextExample', {}, 'Next example (])')}">
<i class="fas fa-chevron-right"></i>
</button>` : ''}
</div>
@@ -508,6 +508,13 @@ export function updateMainDisplay(index) {
const activeImg = galleryState.images[galleryState.activeIndex];
container.style.setProperty('--media-aspect', mediaAspectRatio(activeImg));
// Direction-aware slide makes every switch (wheel, keys, buttons,
// thumbnails) perceivable instead of an instant, unexplained swap
container.classList.remove('slide-from-left', 'slide-from-right');
if (delta !== 0) {
void container.offsetWidth; // restart the animation on rapid switches
container.classList.add(delta > 0 ? 'slide-from-right' : 'slide-from-left');
}
// The badge lives inside the container, so rebuild it together with the media
container.innerHTML = renderMediaItem(
activeImg,
@@ -680,6 +687,177 @@ function setupScrollToExpand(gallery) {
}, { passive: true });
}
// Wheel-navigation tuning: one gesture = one step. Trackpads emit a stream
// of small deltas, so deltas accumulate until the threshold; the cooldown
// keeps the tail of the same gesture from stepping again
const WHEEL_STEP_THRESHOLD = 50;
const WHEEL_COOLDOWN_MS = 250;
const WHEEL_ACCUM_RESET_MS = 200;
/**
* Wheel navigation on the main viewer area. Bound to .gallery-main (not the
* media element) so it works wherever the cursor rests within the viewer
* including over the nav buttons and the dead zones beside the media, and
* regardless of whether the hover-triggered metadata panel is showing.
*
* - Horizontal-dominant deltas (trackpad two-finger swipe) always navigate;
* the modal never scrolls horizontally, so nothing is hijacked.
* - Vertical deltas navigate only when the modal content cannot scroll
* further in that direction (same boundary pass-through pattern as the
* metadata panel's wheel handler), so wheel-scrolling the modal through
* the gallery is never trapped mid-way.
* - Once a boundary crossing triggers a vertical switch, a "wheel session"
* starts: while the pointer stays over .gallery-main, vertical wheel in
* BOTH directions switches examples (down = next, up = prev the reverse
* gesture must undo, not scroll the modal away). The session ends when the
* pointer leaves the area, returning vertical scroll to the modal.
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
function initWheelNavigation(gallery) {
const main = gallery.querySelector('.gallery-main');
if (!main || galleryState.images.length < 2) return;
let accumulated = 0;
let lastEventAt = 0;
let lastStepAt = 0;
let verticalSession = false;
// Leaving the viewer area releases the vertical wheel back to the modal
main.addEventListener('pointerleave', () => {
verticalSession = false;
accumulated = 0;
});
main.addEventListener('wheel', (event) => {
// The metadata panel and media controls keep their own behavior;
// the panel passes boundary scrolls through to the modal by itself
if (event.target.closest('.image-metadata-panel, .media-controls')) return;
const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY);
const delta = horizontal ? event.deltaX : event.deltaY;
if (delta === 0) return;
if (!horizontal && !verticalSession) {
const scroller = main.closest('.modal-content');
if (scroller) {
const atTop = scroller.scrollTop <= 0;
const atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= 1;
if ((delta < 0 && !atTop) || (delta > 0 && !atBottom)) return;
}
}
event.preventDefault();
const now = performance.now();
if (now - lastEventAt > WHEEL_ACCUM_RESET_MS) accumulated = 0;
lastEventAt = now;
if (now - lastStepAt < WHEEL_COOLDOWN_MS) return;
accumulated += delta;
if (Math.abs(accumulated) < WHEEL_STEP_THRESHOLD) return;
const direction = accumulated > 0 ? 1 : -1;
accumulated = 0;
lastStepAt = now;
if (!horizontal) verticalSession = true;
updateMainDisplay(galleryState.activeIndex + direction);
}, { passive: false });
}
// Touch/pen swipe tuning. Mouse is excluded: it already has wheel, keys and
// buttons, and mouse-drag would fight the media's click-to-view gesture
const SWIPE_THRESHOLD_PX = 50;
const SWIPE_CLICK_SUPPRESS_MS = 400;
/**
* Horizontal swipe navigation on the main viewer area (touch/pen). Requires
* `touch-action: pan-y` on .gallery-main so horizontal pans reach these
* handlers while vertical pans still scroll the modal.
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
function initSwipeNavigation(gallery) {
const main = gallery.querySelector('.gallery-main');
if (!main || galleryState.images.length < 2) return;
let startX = 0;
let startY = 0;
let tracking = false;
let lastSwipeAt = 0;
main.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'mouse') return;
// Native video controls own their pointer gestures (scrubbing etc.)
if (event.target.closest('video, .image-metadata-panel, .media-controls, .gallery-nav')) return;
startX = event.clientX;
startY = event.clientY;
tracking = true;
});
main.addEventListener('pointercancel', () => { tracking = false; });
main.addEventListener('pointerup', (event) => {
if (!tracking) return;
tracking = false;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (Math.abs(dx) < SWIPE_THRESHOLD_PX || Math.abs(dx) < Math.abs(dy) * 1.5) return;
lastSwipeAt = performance.now();
updateMainDisplay(galleryState.activeIndex + (dx < 0 ? 1 : -1));
});
// A completed swipe still produces a click on the media — swallow it in
// the capture phase (beats the media element's own handler) so the
// full-size viewer does not open
main.addEventListener('click', (event) => {
if (performance.now() - lastSwipeAt < SWIPE_CLICK_SUPPRESS_MS) {
event.stopPropagation();
event.preventDefault();
}
}, true);
}
/**
* True when the showcase tab is the active pane of an open modal
* @returns {boolean}
*/
function isShowcaseTabVisible() {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab || !showcaseTab.classList.contains('active')) return false;
const modalEl = showcaseTab.closest('.modal');
// No .modal ancestor: standalone/test rendering, treat as visible
if (!modalEl) return true;
return modalEl.classList.contains('show') || modalEl.style.display === 'block';
}
/**
* Typing-target guard for the example shortcuts. Unlike the model-level
* navigation guard, buttons are NOT excluded: clicking a thumbnail or nav
* button leaves focus on it, which would make [ ] feel dead right after the
* most common interaction and buttons consume Space/Enter natively, never
* bracket keys.
* @param {EventTarget|null} target - keydown event target
* @returns {boolean}
*/
function isTypingTarget(target) {
if (!target) return false;
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName);
}
// '[' / ']' switch examples while the gallery is expanded. ArrowLeft/Right
// stay reserved for model-level navigation (ModelModal), and the full-size
// media viewer owns its keys while open.
document.addEventListener('keydown', (event) => {
if (event.key !== '[' && event.key !== ']') return;
if (!galleryState.expanded || galleryState.images.length < 2) return;
if (isTypingTarget(event.target)) return;
if (isMediaViewerOpen()) return;
if (!isShowcaseTabVisible()) return;
event.preventDefault();
updateMainDisplay(galleryState.activeIndex + (event.key === ']' ? 1 : -1));
});
/**
* Defer metadata fetches for video thumbnails until they scroll into view:
* with preload="metadata" on every strip video, expanding the gallery would
@@ -773,6 +951,8 @@ export function initShowcaseContent(gallery) {
const container = gallery.querySelector('.main-media-container');
if (container && galleryState.expanded) {
initMainMediaInteractions(container);
initWheelNavigation(gallery);
initSwipeNavigation(gallery);
// Gallery just (re)rendered expanded: warm the cache for the
// examples adjacent to the active one
prefetchAdjacentMedia();
+6 -1
View File
@@ -12,6 +12,7 @@ import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js';
import { bannerService } from './managers/BannerService.js';
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
import { applyModalBackdropBlurPolicy } from './utils/renderingCapability.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { i18n } from './i18n/index.js';
import { onboardingManager } from './managers/OnboardingManager.js';
@@ -33,7 +34,11 @@ export class AppCore {
if (this.initialized) return;
console.log('AppCore: Initializing...');
// Disable full-viewport backdrop blur under software rendering before
// anything can open a modal (issue #1092)
applyModalBackdropBlurPolicy();
// Initialize i18n first
window.i18n = i18n;
// Wait for i18n to be ready
+66
View File
@@ -0,0 +1,66 @@
/**
* Software-rendering detection for degrading expensive visual effects.
*
* With hardware acceleration disabled (or a GPU blocklisted), Chrome rasterizes
* in software. A full-viewport `backdrop-filter: blur()` then forces a per-frame
* CPU blur over everything painted behind the modal, freezing the entire
* browser (issue #1092). When software rendering is detected we add the
* `no-modal-backdrop-blur` class to <html>, and CSS drops the backdrop blur.
*/
const SOFTWARE_RENDERER_PATTERN = /swiftshader|llvmpipe|softpipe|software|basic render/i;
/**
* Check a WebGL renderer string against known software rasterizers.
* @param {string} renderer - UNMASKED_RENDERER_WEBGL string
* @returns {boolean}
*/
export function isSoftwareRendererString(renderer) {
return SOFTWARE_RENDERER_PATTERN.test(renderer || '');
}
/**
* Read the unmasked WebGL renderer string, or null when unavailable/masked.
* @returns {string|null}
*/
function getWebGLRendererString() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return null;
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = debugInfo
? String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || '')
: '';
const loseContext = gl.getExtension('WEBGL_lose_context');
if (loseContext) loseContext.loseContext();
return renderer || null;
}
/**
* Heuristic: is the browser rasterizing in software?
* - No WebGL at all: no evidence of GPU acceleration, assume software.
* - Masked renderer string or detection failure: cannot tell, keep effects on.
* @returns {boolean}
*/
export function isSoftwareRendering() {
try {
const renderer = getWebGLRendererString();
if (renderer === null) {
return true;
}
return isSoftwareRendererString(renderer);
} catch (error) {
return true;
}
}
/**
* Toggle the blur-disabling class on <html>. Runs once at app startup.
* @param {boolean} [isSoftware] - Override for tests; defaults to detection.
*/
export function applyModalBackdropBlurPolicy(isSoftware = isSoftwareRendering()) {
document.documentElement.classList.toggle('no-modal-backdrop-blur', isSoftware);
}
@@ -210,7 +210,11 @@ describe('RecipeModal resource item interactions', () => {
});
afterEach(() => {
createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts());
// dispose() marks each modal instance dead: pending deferred timers
// (wiring, 500ms reconnect re-renders) are cancelled and in-flight
// async chains become no-ops, so nothing from this test can touch the
// DOM of the next one.
createdModals.forEach(recipeModal => recipeModal.dispose());
createdModals.length = 0;
document.body.innerHTML = '';
delete global.modalManager;
@@ -16,6 +16,7 @@ vi.mock(MEDIA_UTILS_MODULE, () => ({
vi.mock(MEDIA_VIEWER_MODULE, () => ({
openMediaViewer: vi.fn(),
isMediaViewerOpen: vi.fn(() => false),
}));
const PREVIEW_URL = '/loras_static/preview/abc.png';
@@ -340,4 +341,218 @@ describe('Showcase gallery', () => {
expect(loadSpy).toHaveBeenCalled();
loadSpy.mockRestore();
});
it('switches examples on wheel over the main viewer', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const main = document.querySelector('.gallery-main');
main.dispatchEvent(new WheelEvent('wheel', { deltaX: 120, deltaY: 0, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// The one-step-per-gesture cooldown intentionally blocks an immediate
// second step; a fresh gallery (new listener) accepts the next gesture.
// No .modal-content ancestor → no boundary guard, vertical also navigates
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
document.querySelector('.gallery-main')
.dispatchEvent(new WheelEvent('wheel', { deltaX: 0, deltaY: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
});
it('vertical wheel only hijacks at the modal scroll boundary', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `
<div class="modal-content">
<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>
</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const scroller = document.querySelector('.modal-content');
// Mid-scroll: the modal owns vertical wheel, the gallery must not navigate
Object.defineProperties(scroller, {
scrollTop: { value: 100, configurable: true },
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 500, configurable: true },
});
const main = document.querySelector('.gallery-main');
main.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Bottom of the modal: further down-scroll switches to the next example
// (and starts a vertical wheel session — covered by the next test)
Object.defineProperty(scroller, 'scrollTop', { value: 500, configurable: true });
main.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// Leaving the viewer area ends the session: up-scroll away from the top
// belongs to the modal again
main.dispatchEvent(new Event('pointerleave'));
main.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
});
it('keeps vertical wheel in a sticky session once engaged, until pointer leaves', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `
<div class="modal-content">
<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>
</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const scroller = document.querySelector('.modal-content');
// Modal sits at its bottom: down-scroll engages the gallery
Object.defineProperties(scroller, {
scrollTop: { value: 500, configurable: true },
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 500, configurable: true },
});
// The one-step-per-gesture cooldown would block consecutive steps; fake
// the clock so each gesture lands after it
let now = 10000;
const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now);
const main = document.querySelector('.gallery-main');
const wheelUp = () => main.dispatchEvent(
new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }));
const wheelDown = () => main.dispatchEvent(
new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
wheelDown();
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// Reverse gesture must undo: up-scroll switches back to the previous
// example even though the modal is not at its top
now += 300;
wheelUp();
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Pointer leaving the viewer area releases vertical wheel to the modal
now += 300;
main.dispatchEvent(new Event('pointerleave'));
wheelUp();
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
nowSpy.mockRestore();
});
it('ignores wheel events coming from the metadata panel', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
// MediaUtils is mocked, so hoist a panel manually (real code appends it
// as a direct child of .gallery-main)
const main = document.querySelector('.gallery-main');
const panel = document.createElement('div');
panel.className = 'image-metadata-panel visible';
main.appendChild(panel);
panel.dispatchEvent(new WheelEvent('wheel', { deltaX: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
});
it('switches examples with [ and ] while expanded, with guards', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
const { isMediaViewerOpen } = await import(MEDIA_VIEWER_MODULE);
document.body.innerHTML = `<div id="showcase-tab" class="tab-pane active">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
document.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
document.dispatchEvent(new KeyboardEvent('keydown', { key: '[', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Typing in a field: the key belongs to the field
const input = document.createElement('input');
document.body.appendChild(input);
input.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
input.remove();
// Focus resting on a button (e.g. right after clicking a thumbnail or nav
// button) must NOT deaden the keys — buttons consume Space/Enter natively
document.querySelector('#galleryNextBtn')
.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
document.dispatchEvent(new KeyboardEvent('keydown', { key: '[', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Full-size media viewer open: it owns the keys
isMediaViewerOpen.mockReturnValueOnce(true);
document.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Another tab active: examples must not change behind the scenes
document.getElementById('showcase-tab').classList.remove('active');
document.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
});
it('switches examples on touch swipe and suppresses the follow-up click', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
const { openMediaViewer } = await import(MEDIA_VIEWER_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const main = document.querySelector('.gallery-main');
const img = main.querySelector('.media-wrapper img');
// A plain tap still opens the full-size viewer
img.click();
expect(openMediaViewer).toHaveBeenCalledTimes(1);
// jsdom lacks PointerEvent; MouseEvent carries clientX/clientY and its
// undefined pointerType passes the non-mouse guard
main.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 100 }));
main.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 100, clientY: 110 }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// The click synthesized after the swipe must not open the viewer
document.querySelector('.gallery-main .media-wrapper img').click();
expect(openMediaViewer).toHaveBeenCalledTimes(1);
// A short drag below the threshold neither navigates nor eats the click
main.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 100 }));
main.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 280, clientY: 100 }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
});
it('marks the main viewer with a direction-aware slide class on switches', async () => {
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
const container = document.getElementById('mainMediaContainer');
// galleryState.activeIndex leaks across tests → anchor on the actual index
const start = Number(document.querySelector('.gallery-thumb.active')?.dataset.index || 0);
updateMainDisplay(start); // same index: no direction, no slide
expect(container.classList.contains('slide-from-right')).toBe(false);
expect(container.classList.contains('slide-from-left')).toBe(false);
updateMainDisplay(start + 1); // forward
expect(container.classList.contains('slide-from-right')).toBe(true);
expect(container.classList.contains('slide-from-left')).toBe(false);
updateMainDisplay(start); // backward
expect(container.classList.contains('slide-from-left')).toBe(true);
expect(container.classList.contains('slide-from-right')).toBe(false);
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
isSoftwareRendererString,
applyModalBackdropBlurPolicy,
} from '../../../static/js/utils/renderingCapability.js';
describe('isSoftwareRendererString', () => {
it('detects SwiftShader (Chrome with hardware acceleration disabled)', () => {
expect(isSoftwareRendererString(
'WebKit WebGL SwiftShader'
)).toBe(true);
expect(isSoftwareRendererString(
'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)'
)).toBe(true);
});
it('detects Mesa software rasterizers (Linux)', () => {
expect(isSoftwareRendererString('llvmpipe (LLVM 17.0.6, 256 bits)')).toBe(true);
expect(isSoftwareRendererString('softpipe')).toBe(true);
});
it('detects generic software renderer strings', () => {
expect(isSoftwareRendererString('Software Renderer')).toBe(true);
expect(isSoftwareRendererString('Microsoft Basic Render Driver')).toBe(true);
});
it('accepts hardware GPU strings', () => {
expect(isSoftwareRendererString(
'ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11 vs_5_0 ps_5_0, D3D11)'
)).toBe(false);
expect(isSoftwareRendererString(
'ANGLE (AMD, AMD Radeon RX 7900 XTX (0x0000744C) Direct3D11 vs_5_0 ps_5_0, D3D11)'
)).toBe(false);
expect(isSoftwareRendererString('Apple M4 Pro')).toBe(false);
expect(isSoftwareRendererString('Mesa Intel(R) UHD Graphics 620 (KBL GT2)')).toBe(false);
});
it('handles empty input', () => {
expect(isSoftwareRendererString('')).toBe(false);
expect(isSoftwareRendererString(null)).toBe(false);
});
});
describe('applyModalBackdropBlurPolicy', () => {
beforeEach(() => {
document.documentElement.classList.remove('no-modal-backdrop-blur');
});
it('adds the disabling class under software rendering', () => {
applyModalBackdropBlurPolicy(true);
expect(document.documentElement.classList.contains('no-modal-backdrop-blur')).toBe(true);
});
it('removes the disabling class under hardware rendering', () => {
document.documentElement.classList.add('no-modal-backdrop-blur');
applyModalBackdropBlurPolicy(false);
expect(document.documentElement.classList.contains('no-modal-backdrop-blur')).toBe(false);
});
});
+2 -2
View File
@@ -58,8 +58,8 @@ export async function updateConnectedLoraInfoNodes(node, selection) {
return;
}
// No selection or inactive — clear the display on all connected info nodes
if (!selection?.name || !selection?.active) {
// No selection — clear the display on all connected info nodes
if (!selection?.name) {
for (const infoNode of infoNodes) {
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1;
if (typeof infoNode._setLoraInfo === "function") {