/** * パンフレットのスライド閲覧。 * * - ← / → キー、スワイプ、左右ボタン、サムネイルでページ送り * - 横長の画面では見開き表示(1 ページ目は表紙として単独、以降 2-3, 4-5 … の組) * - 全画面表示 * - 開いたときに閲覧数を 1 回だけ記録する */ (function renderPamphletSlideViewer() { 'use strict'; const { useState, useEffect, useCallback, useMemo, useRef } = React; const rootElement = document.getElementById('slide-viewer-root'); const pamphlet = JSON.parse(rootElement.dataset.pamphlet); const viewEndpointUrl = rootElement.dataset.viewEndpoint; const SWIPE_THRESHOLD_PX = 50; const WIDE_SCREEN_QUERY = '(min-width: 1100px)'; /** 画面に一度に出すページの組。単ページなら [[1],[2],…]、見開きなら [[1],[2,3],[4,5],…] */ function buildSlides(pages, isSpreadMode) { if (!isSpreadMode || pages.length <= 2) { return pages.map((page) => [page]); } const slides = [[pages[0]]]; for (let pageIndex = 1; pageIndex < pages.length; pageIndex += 2) { slides.push(pages.slice(pageIndex, pageIndex + 2)); } return slides; } function useWideScreen() { const [isWideScreen, setIsWideScreen] = useState(() => window.matchMedia(WIDE_SCREEN_QUERY).matches); useEffect(() => { const mediaQuery = window.matchMedia(WIDE_SCREEN_QUERY); const handleMediaChange = (changeEvent) => setIsWideScreen(changeEvent.matches); mediaQuery.addEventListener('change', handleMediaChange); return () => mediaQuery.removeEventListener('change', handleMediaChange); }, []); return isWideScreen; } function SlideViewerApp() { const viewerElementRef = useRef(null); const swipeStartXRef = useRef(null); const isWideScreen = useWideScreen(); const canUseSpreadMode = pamphlet.kind === 'pamphlet' && pamphlet.pages.length > 2; const [prefersSpreadMode, setPrefersSpreadMode] = useState(true); const isSpreadMode = canUseSpreadMode && isWideScreen && prefersSpreadMode; const slides = useMemo(() => buildSlides(pamphlet.pages, isSpreadMode), [isSpreadMode]); const [currentPageNumber, setCurrentPageNumber] = useState(1); const [isFullscreen, setIsFullscreen] = useState(false); // 見開きの切り替えでスライドの区切りが変わっても、見ていたページを含むスライドに留まる const currentSlideIndex = Math.max(0, slides.findIndex((slidePages) => slidePages.some((page) => page.number === currentPageNumber))); const currentSlidePages = slides[currentSlideIndex] || []; const isFirstSlide = currentSlideIndex === 0; const isLastSlide = currentSlideIndex === slides.length - 1; const goToSlide = useCallback((targetSlideIndex) => { const boundedSlideIndex = Math.min(Math.max(targetSlideIndex, 0), slides.length - 1); setCurrentPageNumber(slides[boundedSlideIndex][0].number); }, [slides]); const goToPreviousSlide = useCallback(() => goToSlide(currentSlideIndex - 1), [goToSlide, currentSlideIndex]); const goToNextSlide = useCallback(() => goToSlide(currentSlideIndex + 1), [goToSlide, currentSlideIndex]); useEffect(() => { requestJson(viewEndpointUrl, { method: 'POST' }).catch(() => {}); }, []); useEffect(() => { const handleKeyDown = (keyboardEvent) => { if (keyboardEvent.target.closest('input, textarea, select')) return; if (keyboardEvent.key === 'ArrowRight') goToNextSlide(); if (keyboardEvent.key === 'ArrowLeft') goToPreviousSlide(); if (keyboardEvent.key === 'Home') goToSlide(0); if (keyboardEvent.key === 'End') goToSlide(slides.length - 1); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [goToNextSlide, goToPreviousSlide, goToSlide, slides.length]); // 前後のスライドの画像を先に読み込んでおき、めくったときに白くならないようにする useEffect(() => { [slides[currentSlideIndex - 1], slides[currentSlideIndex + 1]] .filter(Boolean) .flat() .forEach((adjacentPage) => { new Image().src = adjacentPage.image_url; }); }, [slides, currentSlideIndex]); useEffect(() => { const handleFullscreenChange = () => setIsFullscreen(document.fullscreenElement === viewerElementRef.current); document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); const handleFullscreenToggle = useCallback(() => { if (document.fullscreenElement) { document.exitFullscreen(); } else if (viewerElementRef.current.requestFullscreen) { viewerElementRef.current.requestFullscreen(); } }, []); const handlePointerDown = useCallback((pointerEvent) => { swipeStartXRef.current = pointerEvent.clientX; }, []); const handlePointerUp = useCallback((pointerEvent) => { if (swipeStartXRef.current === null) return; const swipeDistanceX = pointerEvent.clientX - swipeStartXRef.current; swipeStartXRef.current = null; if (swipeDistanceX <= -SWIPE_THRESHOLD_PX) goToNextSlide(); if (swipeDistanceX >= SWIPE_THRESHOLD_PX) goToPreviousSlide(); }, [goToNextSlide, goToPreviousSlide]); if (pamphlet.pages.length === 0) { return
ページがまだありません。
; } const pageLabel = currentSlidePages.map((page) => page.number).join('-'); return (