diff --git a/locales/de.json b/locales/de.json index 35c803a6..04d4f533 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index cc87adc4..2a26b773 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 721f9e51..f32d6379 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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í", diff --git a/locales/fr.json b/locales/fr.json index 541e38b6..7f3d6f6e 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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", diff --git a/locales/he.json b/locales/he.json index 62a6339a..f4c5414b 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1664,8 +1664,8 @@ "showCount": "הצג דוגמאות ({count})", "hideExamples": "הסתר דוגמאות", "addExamples": "הוסף דוגמאות", - "previousExample": "דוגמה קודמת", - "nextExample": "דוגמה הבאה", + "previousExample": "דוגמה קודמת ([)", + "nextExample": "דוגמה הבאה (])", "noExamples": "אין תמונות דוגמה זמינות", "addMoreExamples": "הוסף עוד דוגמאות", "dragDrop": "גרור ושחרר תמונות או סרטונים כאן", diff --git a/locales/ja.json b/locales/ja.json index 3cfc07cc..1a2fec20 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1664,8 +1664,8 @@ "showCount": "例を表示({count})", "hideExamples": "例を非表示", "addExamples": "例を追加", - "previousExample": "前の例", - "nextExample": "次の例", + "previousExample": "前の例([)", + "nextExample": "次の例(])", "noExamples": "利用可能な例画像がありません", "addMoreExamples": "さらに例を追加", "dragDrop": "画像または動画をここにドラッグ&ドロップ", diff --git a/locales/ko.json b/locales/ko.json index abcdb9f0..5ab4aba8 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1664,8 +1664,8 @@ "showCount": "예시 보기 ({count})", "hideExamples": "예시 숨기기", "addExamples": "예시 추가", - "previousExample": "이전 예시", - "nextExample": "다음 예시", + "previousExample": "이전 예시([)", + "nextExample": "다음 예시(])", "noExamples": "사용 가능한 예시 이미지가 없습니다", "addMoreExamples": "예시 더 추가", "dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요", diff --git a/locales/ru.json b/locales/ru.json index 42545594..59ddfa80 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1664,8 +1664,8 @@ "showCount": "Показать примеры ({count})", "hideExamples": "Скрыть примеры", "addExamples": "Добавить примеры", - "previousExample": "Предыдущий пример", - "nextExample": "Следующий пример", + "previousExample": "Предыдущий пример ([)", + "nextExample": "Следующий пример (])", "noExamples": "Примеры изображений недоступны", "addMoreExamples": "Добавить ещё примеры", "dragDrop": "Перетащите изображения или видео сюда", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 792e329b..9fe1ab22 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1664,8 +1664,8 @@ "showCount": "显示示例({count})", "hideExamples": "隐藏示例", "addExamples": "添加示例", - "previousExample": "上一个示例", - "nextExample": "下一个示例", + "previousExample": "上一个示例([)", + "nextExample": "下一个示例(])", "noExamples": "暂无示例图片", "addMoreExamples": "添加更多示例", "dragDrop": "将图片或视频拖放到此处", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 037305b8..78a53bb6 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1664,8 +1664,8 @@ "showCount": "顯示範例({count})", "hideExamples": "隱藏範例", "addExamples": "新增範例", - "previousExample": "上一個範例", - "nextExample": "下一個範例", + "previousExample": "上一個範例([)", + "nextExample": "下一個範例(])", "noExamples": "沒有可用的範例圖片", "addMoreExamples": "新增更多範例", "dragDrop": "拖放圖片或影片到此處", diff --git a/static/css/components/lora-modal/showcase.css b/static/css/components/lora-modal/showcase.css index fba7f75d..869b4205 100644 --- a/static/css/components/lora-modal/showcase.css +++ b/static/css/components/lora-modal/showcase.css @@ -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; diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js index f8f4c794..d9baf6e6 100644 --- a/static/js/components/shared/showcase/ShowcaseView.js +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -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)} - ${showNav ? ` - ` : ''} @@ -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(); diff --git a/tests/frontend/components/showcase.gallery.test.js b/tests/frontend/components/showcase.gallery.test.js index 79e19c58..760733e8 100644 --- a/tests/frontend/components/showcase.gallery.test.js +++ b/tests/frontend/components/showcase.gallery.test.js @@ -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 = `
${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}
`; + 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 = `
${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}
`; + 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 = ` + `; + 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 = ` + `; + 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 = `
${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}
`; + 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 = `
${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}
`; + 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 = `
${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}
`; + 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 = `
${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}
`; + 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); + }); });