mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
Compare commits
4 Commits
34ca14d7fc
...
6b41c3bbb4
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b41c3bbb4 | |||
| b37238d790 | |||
| bc33e32c6f | |||
| 49704d801c |
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1664,8 +1664,8 @@
|
||||
"showCount": "הצג דוגמאות ({count})",
|
||||
"hideExamples": "הסתר דוגמאות",
|
||||
"addExamples": "הוסף דוגמאות",
|
||||
"previousExample": "דוגמה קודמת",
|
||||
"nextExample": "דוגמה הבאה",
|
||||
"previousExample": "דוגמה קודמת ([)",
|
||||
"nextExample": "דוגמה הבאה (])",
|
||||
"noExamples": "אין תמונות דוגמה זמינות",
|
||||
"addMoreExamples": "הוסף עוד דוגמאות",
|
||||
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
|
||||
|
||||
+2
-2
@@ -1664,8 +1664,8 @@
|
||||
"showCount": "例を表示({count})",
|
||||
"hideExamples": "例を非表示",
|
||||
"addExamples": "例を追加",
|
||||
"previousExample": "前の例",
|
||||
"nextExample": "次の例",
|
||||
"previousExample": "前の例([)",
|
||||
"nextExample": "次の例(])",
|
||||
"noExamples": "利用可能な例画像がありません",
|
||||
"addMoreExamples": "さらに例を追加",
|
||||
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
|
||||
|
||||
+2
-2
@@ -1664,8 +1664,8 @@
|
||||
"showCount": "예시 보기 ({count})",
|
||||
"hideExamples": "예시 숨기기",
|
||||
"addExamples": "예시 추가",
|
||||
"previousExample": "이전 예시",
|
||||
"nextExample": "다음 예시",
|
||||
"previousExample": "이전 예시([)",
|
||||
"nextExample": "다음 예시(])",
|
||||
"noExamples": "사용 가능한 예시 이미지가 없습니다",
|
||||
"addMoreExamples": "예시 더 추가",
|
||||
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
|
||||
|
||||
+2
-2
@@ -1664,8 +1664,8 @@
|
||||
"showCount": "Показать примеры ({count})",
|
||||
"hideExamples": "Скрыть примеры",
|
||||
"addExamples": "Добавить примеры",
|
||||
"previousExample": "Предыдущий пример",
|
||||
"nextExample": "Следующий пример",
|
||||
"previousExample": "Предыдущий пример ([)",
|
||||
"nextExample": "Следующий пример (])",
|
||||
"noExamples": "Примеры изображений недоступны",
|
||||
"addMoreExamples": "Добавить ещё примеры",
|
||||
"dragDrop": "Перетащите изображения или видео сюда",
|
||||
|
||||
+2
-2
@@ -1664,8 +1664,8 @@
|
||||
"showCount": "显示示例({count})",
|
||||
"hideExamples": "隐藏示例",
|
||||
"addExamples": "添加示例",
|
||||
"previousExample": "上一个示例",
|
||||
"nextExample": "下一个示例",
|
||||
"previousExample": "上一个示例([)",
|
||||
"nextExample": "下一个示例(])",
|
||||
"noExamples": "暂无示例图片",
|
||||
"addMoreExamples": "添加更多示例",
|
||||
"dragDrop": "将图片或视频拖放到此处",
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user