mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
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)
This commit is contained in:
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "Beispiele anzeigen ({count})",
|
"showCount": "Beispiele anzeigen ({count})",
|
||||||
"hideExamples": "Beispiele ausblenden",
|
"hideExamples": "Beispiele ausblenden",
|
||||||
"addExamples": "Beispiele hinzufügen",
|
"addExamples": "Beispiele hinzufügen",
|
||||||
"previousExample": "Vorheriges Beispiel",
|
"previousExample": "Vorheriges Beispiel ([)",
|
||||||
"nextExample": "Nächstes Beispiel",
|
"nextExample": "Nächstes Beispiel (])",
|
||||||
"noExamples": "Keine Beispielbilder verfügbar",
|
"noExamples": "Keine Beispielbilder verfügbar",
|
||||||
"addMoreExamples": "Weitere Beispiele hinzufügen",
|
"addMoreExamples": "Weitere Beispiele hinzufügen",
|
||||||
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
|
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "Show examples ({count})",
|
"showCount": "Show examples ({count})",
|
||||||
"hideExamples": "Hide examples",
|
"hideExamples": "Hide examples",
|
||||||
"addExamples": "Add examples",
|
"addExamples": "Add examples",
|
||||||
"previousExample": "Previous example",
|
"previousExample": "Previous example ([)",
|
||||||
"nextExample": "Next example",
|
"nextExample": "Next example (])",
|
||||||
"noExamples": "No example images available",
|
"noExamples": "No example images available",
|
||||||
"addMoreExamples": "Add more examples",
|
"addMoreExamples": "Add more examples",
|
||||||
"dragDrop": "Drag & drop images or videos here",
|
"dragDrop": "Drag & drop images or videos here",
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "Mostrar ejemplos ({count})",
|
"showCount": "Mostrar ejemplos ({count})",
|
||||||
"hideExamples": "Ocultar ejemplos",
|
"hideExamples": "Ocultar ejemplos",
|
||||||
"addExamples": "Añadir ejemplos",
|
"addExamples": "Añadir ejemplos",
|
||||||
"previousExample": "Ejemplo anterior",
|
"previousExample": "Ejemplo anterior ([)",
|
||||||
"nextExample": "Ejemplo siguiente",
|
"nextExample": "Ejemplo siguiente (])",
|
||||||
"noExamples": "No hay imágenes de ejemplo disponibles",
|
"noExamples": "No hay imágenes de ejemplo disponibles",
|
||||||
"addMoreExamples": "Añadir más ejemplos",
|
"addMoreExamples": "Añadir más ejemplos",
|
||||||
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
|
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "Afficher les exemples ({count})",
|
"showCount": "Afficher les exemples ({count})",
|
||||||
"hideExamples": "Masquer les exemples",
|
"hideExamples": "Masquer les exemples",
|
||||||
"addExamples": "Ajouter des exemples",
|
"addExamples": "Ajouter des exemples",
|
||||||
"previousExample": "Exemple précédent",
|
"previousExample": "Exemple précédent ([)",
|
||||||
"nextExample": "Exemple suivant",
|
"nextExample": "Exemple suivant (])",
|
||||||
"noExamples": "Aucune image d'exemple disponible",
|
"noExamples": "Aucune image d'exemple disponible",
|
||||||
"addMoreExamples": "Ajouter d'autres exemples",
|
"addMoreExamples": "Ajouter d'autres exemples",
|
||||||
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
|
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "הצג דוגמאות ({count})",
|
"showCount": "הצג דוגמאות ({count})",
|
||||||
"hideExamples": "הסתר דוגמאות",
|
"hideExamples": "הסתר דוגמאות",
|
||||||
"addExamples": "הוסף דוגמאות",
|
"addExamples": "הוסף דוגמאות",
|
||||||
"previousExample": "דוגמה קודמת",
|
"previousExample": "דוגמה קודמת ([)",
|
||||||
"nextExample": "דוגמה הבאה",
|
"nextExample": "דוגמה הבאה (])",
|
||||||
"noExamples": "אין תמונות דוגמה זמינות",
|
"noExamples": "אין תמונות דוגמה זמינות",
|
||||||
"addMoreExamples": "הוסף עוד דוגמאות",
|
"addMoreExamples": "הוסף עוד דוגמאות",
|
||||||
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
|
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "例を表示({count})",
|
"showCount": "例を表示({count})",
|
||||||
"hideExamples": "例を非表示",
|
"hideExamples": "例を非表示",
|
||||||
"addExamples": "例を追加",
|
"addExamples": "例を追加",
|
||||||
"previousExample": "前の例",
|
"previousExample": "前の例([)",
|
||||||
"nextExample": "次の例",
|
"nextExample": "次の例(])",
|
||||||
"noExamples": "利用可能な例画像がありません",
|
"noExamples": "利用可能な例画像がありません",
|
||||||
"addMoreExamples": "さらに例を追加",
|
"addMoreExamples": "さらに例を追加",
|
||||||
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
|
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "예시 보기 ({count})",
|
"showCount": "예시 보기 ({count})",
|
||||||
"hideExamples": "예시 숨기기",
|
"hideExamples": "예시 숨기기",
|
||||||
"addExamples": "예시 추가",
|
"addExamples": "예시 추가",
|
||||||
"previousExample": "이전 예시",
|
"previousExample": "이전 예시([)",
|
||||||
"nextExample": "다음 예시",
|
"nextExample": "다음 예시(])",
|
||||||
"noExamples": "사용 가능한 예시 이미지가 없습니다",
|
"noExamples": "사용 가능한 예시 이미지가 없습니다",
|
||||||
"addMoreExamples": "예시 더 추가",
|
"addMoreExamples": "예시 더 추가",
|
||||||
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
|
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "Показать примеры ({count})",
|
"showCount": "Показать примеры ({count})",
|
||||||
"hideExamples": "Скрыть примеры",
|
"hideExamples": "Скрыть примеры",
|
||||||
"addExamples": "Добавить примеры",
|
"addExamples": "Добавить примеры",
|
||||||
"previousExample": "Предыдущий пример",
|
"previousExample": "Предыдущий пример ([)",
|
||||||
"nextExample": "Следующий пример",
|
"nextExample": "Следующий пример (])",
|
||||||
"noExamples": "Примеры изображений недоступны",
|
"noExamples": "Примеры изображений недоступны",
|
||||||
"addMoreExamples": "Добавить ещё примеры",
|
"addMoreExamples": "Добавить ещё примеры",
|
||||||
"dragDrop": "Перетащите изображения или видео сюда",
|
"dragDrop": "Перетащите изображения или видео сюда",
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "显示示例({count})",
|
"showCount": "显示示例({count})",
|
||||||
"hideExamples": "隐藏示例",
|
"hideExamples": "隐藏示例",
|
||||||
"addExamples": "添加示例",
|
"addExamples": "添加示例",
|
||||||
"previousExample": "上一个示例",
|
"previousExample": "上一个示例([)",
|
||||||
"nextExample": "下一个示例",
|
"nextExample": "下一个示例(])",
|
||||||
"noExamples": "暂无示例图片",
|
"noExamples": "暂无示例图片",
|
||||||
"addMoreExamples": "添加更多示例",
|
"addMoreExamples": "添加更多示例",
|
||||||
"dragDrop": "将图片或视频拖放到此处",
|
"dragDrop": "将图片或视频拖放到此处",
|
||||||
|
|||||||
+2
-2
@@ -1664,8 +1664,8 @@
|
|||||||
"showCount": "顯示範例({count})",
|
"showCount": "顯示範例({count})",
|
||||||
"hideExamples": "隱藏範例",
|
"hideExamples": "隱藏範例",
|
||||||
"addExamples": "新增範例",
|
"addExamples": "新增範例",
|
||||||
"previousExample": "上一個範例",
|
"previousExample": "上一個範例([)",
|
||||||
"nextExample": "下一個範例",
|
"nextExample": "下一個範例(])",
|
||||||
"noExamples": "沒有可用的範例圖片",
|
"noExamples": "沒有可用的範例圖片",
|
||||||
"addMoreExamples": "新增更多範例",
|
"addMoreExamples": "新增更多範例",
|
||||||
"dragDrop": "拖放圖片或影片到此處",
|
"dragDrop": "拖放圖片或影片到此處",
|
||||||
|
|||||||
@@ -115,6 +115,9 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: var(--border-radius-sm);
|
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 {
|
.main-media-container {
|
||||||
@@ -134,6 +137,32 @@
|
|||||||
margin-bottom: 0;
|
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 img,
|
||||||
.main-media-container .media-wrapper video {
|
.main-media-container .media-wrapper video {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||||
import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.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';
|
import { escapeAttribute } from '../utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,10 +231,10 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
|
|||||||
${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)}
|
${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)}
|
||||||
${renderPositionBadge(positionText)}
|
${renderPositionBadge(positionText)}
|
||||||
</div>
|
</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>
|
<i class="fas fa-chevron-left"></i>
|
||||||
</button>
|
</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>
|
<i class="fas fa-chevron-right"></i>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -508,6 +508,13 @@ export function updateMainDisplay(index) {
|
|||||||
|
|
||||||
const activeImg = galleryState.images[galleryState.activeIndex];
|
const activeImg = galleryState.images[galleryState.activeIndex];
|
||||||
container.style.setProperty('--media-aspect', mediaAspectRatio(activeImg));
|
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
|
// The badge lives inside the container, so rebuild it together with the media
|
||||||
container.innerHTML = renderMediaItem(
|
container.innerHTML = renderMediaItem(
|
||||||
activeImg,
|
activeImg,
|
||||||
@@ -680,6 +687,177 @@ function setupScrollToExpand(gallery) {
|
|||||||
}, { passive: true });
|
}, { 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:
|
* Defer metadata fetches for video thumbnails until they scroll into view:
|
||||||
* with preload="metadata" on every strip video, expanding the gallery would
|
* 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');
|
const container = gallery.querySelector('.main-media-container');
|
||||||
if (container && galleryState.expanded) {
|
if (container && galleryState.expanded) {
|
||||||
initMainMediaInteractions(container);
|
initMainMediaInteractions(container);
|
||||||
|
initWheelNavigation(gallery);
|
||||||
|
initSwipeNavigation(gallery);
|
||||||
// Gallery just (re)rendered expanded: warm the cache for the
|
// Gallery just (re)rendered expanded: warm the cache for the
|
||||||
// examples adjacent to the active one
|
// examples adjacent to the active one
|
||||||
prefetchAdjacentMedia();
|
prefetchAdjacentMedia();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock(MEDIA_UTILS_MODULE, () => ({
|
|||||||
|
|
||||||
vi.mock(MEDIA_VIEWER_MODULE, () => ({
|
vi.mock(MEDIA_VIEWER_MODULE, () => ({
|
||||||
openMediaViewer: vi.fn(),
|
openMediaViewer: vi.fn(),
|
||||||
|
isMediaViewerOpen: vi.fn(() => false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const PREVIEW_URL = '/loras_static/preview/abc.png';
|
const PREVIEW_URL = '/loras_static/preview/abc.png';
|
||||||
@@ -340,4 +341,218 @@ describe('Showcase gallery', () => {
|
|||||||
expect(loadSpy).toHaveBeenCalled();
|
expect(loadSpy).toHaveBeenCalled();
|
||||||
loadSpy.mockRestore();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user