|
|
|
@@ -422,83 +422,31 @@ function FullscreenViewer({ |
|
|
|
} |
|
|
|
|
|
|
|
function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) { |
|
|
|
const containerRef = useRef<HTMLDivElement>(null); |
|
|
|
const flipRef = useRef<import('@/lib/page-flip').PageFlip | null>(null); |
|
|
|
const [currentPage, setCurrentPage] = useState(0); |
|
|
|
const [pageCount, setPageCount] = useState(pages.length); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!containerRef.current || pages.length === 0) return; |
|
|
|
let cancelled = false; |
|
|
|
|
|
|
|
// page-flip's destroy() removes its own block from the DOM. Give it a child |
|
|
|
// div we own so React's managed container stays intact across StrictMode cycles. |
|
|
|
const block = document.createElement('div'); |
|
|
|
block.style.width = '100%'; |
|
|
|
block.style.height = '100%'; |
|
|
|
containerRef.current.appendChild(block); |
|
|
|
|
|
|
|
// HTML mode: each page is a div with an <img> using object-fit:contain so |
|
|
|
// mixed aspect ratios get letterboxed with white margins (like a real book |
|
|
|
// page) instead of being stretched to fill the slot. |
|
|
|
const pageElements = pages.map((url) => { |
|
|
|
const page = document.createElement('div'); |
|
|
|
page.style.cssText = 'background:#ffffff;width:100%;height:100%;overflow:hidden;display:flex;align-items:center;justify-content:center;'; |
|
|
|
const img = document.createElement('img'); |
|
|
|
img.src = url; |
|
|
|
img.draggable = false; |
|
|
|
img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;display:block;pointer-events:none;user-select:none;'; |
|
|
|
page.appendChild(img); |
|
|
|
return page; |
|
|
|
}); |
|
|
|
|
|
|
|
import('@/lib/page-flip').then(({ PageFlip }) => { |
|
|
|
if (cancelled) return; |
|
|
|
const flip = new PageFlip(block, { |
|
|
|
width: 550, |
|
|
|
height: 733, |
|
|
|
size: 'stretch', |
|
|
|
minWidth: 315, |
|
|
|
maxWidth: 1400, |
|
|
|
minHeight: 420, |
|
|
|
maxHeight: 900, |
|
|
|
drawShadow: true, |
|
|
|
flippingTime: 800, |
|
|
|
usePortrait: true, |
|
|
|
maxShadowOpacity: 0.5, |
|
|
|
showCover: false, |
|
|
|
mobileScrollSupport: false, |
|
|
|
useMouseEvents: true, |
|
|
|
swipeDistance: 30, |
|
|
|
showPageCorners: true, |
|
|
|
disableFlipByClick: false, |
|
|
|
}); |
|
|
|
|
|
|
|
flip.on('flip', (e) => { |
|
|
|
const newPage = typeof e.data === 'number' ? e.data : 0; |
|
|
|
setCurrentPage(newPage); |
|
|
|
}); |
|
|
|
flip.on('init', () => { |
|
|
|
setPageCount(flip.getPageCount()); |
|
|
|
}); |
|
|
|
|
|
|
|
flip.loadFromHTML(pageElements); |
|
|
|
flipRef.current = flip; |
|
|
|
}).catch(() => {}); |
|
|
|
|
|
|
|
return () => { |
|
|
|
cancelled = true; |
|
|
|
if (flipRef.current) { |
|
|
|
try { flipRef.current.destroy(); } catch {} |
|
|
|
flipRef.current = null; |
|
|
|
} |
|
|
|
try { block.remove(); } catch {} |
|
|
|
}; |
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps |
|
|
|
}, []); |
|
|
|
|
|
|
|
const goNext = useCallback(() => flipRef.current?.flipNext(), []); |
|
|
|
const goPrev = useCallback(() => flipRef.current?.flipPrev(), []); |
|
|
|
const [spread, setSpread] = useState(0); |
|
|
|
const [flipping, setFlipping] = useState<'forward' | 'backward' | null>(null); |
|
|
|
const dragStartX = useRef<number | null>(null); |
|
|
|
|
|
|
|
const totalSpreads = Math.max(1, Math.ceil(pages.length / 2)); |
|
|
|
const getPage = (i: number) => (i >= 0 && i < pages.length ? pages[i] : ''); |
|
|
|
|
|
|
|
const leftIdx = spread * 2; |
|
|
|
const rightIdx = spread * 2 + 1; |
|
|
|
const nextLeftIdx = (spread + 1) * 2; |
|
|
|
const nextRightIdx = (spread + 1) * 2 + 1; |
|
|
|
const prevLeftIdx = (spread - 1) * 2; |
|
|
|
const prevRightIdx = (spread - 1) * 2 + 1; |
|
|
|
|
|
|
|
const goNext = useCallback(() => { |
|
|
|
if (flipping !== null || spread >= totalSpreads - 1) return; |
|
|
|
setFlipping('forward'); |
|
|
|
window.setTimeout(() => { setSpread(s => s + 1); setFlipping(null); }, 700); |
|
|
|
}, [flipping, spread, totalSpreads]); |
|
|
|
|
|
|
|
const goPrev = useCallback(() => { |
|
|
|
if (flipping !== null || spread <= 0) return; |
|
|
|
setFlipping('backward'); |
|
|
|
window.setTimeout(() => { setSpread(s => s - 1); setFlipping(null); }, 700); |
|
|
|
}, [flipping, spread]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const onKey = (e: KeyboardEvent) => { |
|
|
|
@@ -510,8 +458,39 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) |
|
|
|
return () => window.removeEventListener('keydown', onKey); |
|
|
|
}, [onClose, goNext, goPrev]); |
|
|
|
|
|
|
|
const onPointerDown = (e: React.PointerEvent) => { dragStartX.current = e.clientX; }; |
|
|
|
const onPointerUp = (e: React.PointerEvent) => { |
|
|
|
if (dragStartX.current === null) return; |
|
|
|
const dx = e.clientX - dragStartX.current; |
|
|
|
if (Math.abs(dx) > 50) (dx < 0 ? goNext() : goPrev()); |
|
|
|
dragStartX.current = null; |
|
|
|
}; |
|
|
|
const onPointerCancel = () => { dragStartX.current = null; }; |
|
|
|
|
|
|
|
const visibleLeft = flipping === 'backward' ? getPage(prevLeftIdx) : getPage(leftIdx); |
|
|
|
const visibleRight = flipping === 'forward' ? getPage(nextRightIdx) : getPage(rightIdx); |
|
|
|
|
|
|
|
const currentPageNum = Math.min(leftIdx + 1, pages.length); |
|
|
|
const lastPageNum = Math.min(rightIdx + 1, pages.length); |
|
|
|
const indicatorLabel = currentPageNum === lastPageNum |
|
|
|
? `${currentPageNum} / ${pages.length}` |
|
|
|
: `${currentPageNum}-${lastPageNum} / ${pages.length}`; |
|
|
|
|
|
|
|
const pageImgClass = 'w-full h-full object-contain'; |
|
|
|
|
|
|
|
return ( |
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 select-none"> |
|
|
|
<div |
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 select-none" |
|
|
|
onPointerDown={onPointerDown} |
|
|
|
onPointerUp={onPointerUp} |
|
|
|
onPointerCancel={onPointerCancel} |
|
|
|
style={{ perspective: '2500px', touchAction: 'pan-y' }} |
|
|
|
> |
|
|
|
<style dangerouslySetInnerHTML={{ __html: ` |
|
|
|
@keyframes flipBookForward { from { transform: rotateY(0deg); } to { transform: rotateY(-180deg); } } |
|
|
|
@keyframes flipBookBackward { from { transform: rotateY(0deg); } to { transform: rotateY(180deg); } } |
|
|
|
`}} /> |
|
|
|
|
|
|
|
<button |
|
|
|
onClick={onClose} |
|
|
|
className="absolute top-4 right-4 bg-white/10 hover:bg-white/25 text-white w-11 h-11 flex items-center justify-center rounded-full text-xl z-30 transition-colors" |
|
|
|
@@ -519,49 +498,103 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) |
|
|
|
aria-label="Chiudi" |
|
|
|
>✕</button> |
|
|
|
|
|
|
|
<style dangerouslySetInnerHTML={{ __html: ` |
|
|
|
.stf__item[class~="--left"]::after, |
|
|
|
.stf__item[class~="--right"]::after { |
|
|
|
content: ''; |
|
|
|
position: absolute; |
|
|
|
top: 0; |
|
|
|
bottom: 0; |
|
|
|
width: 50px; |
|
|
|
pointer-events: none; |
|
|
|
z-index: 10; |
|
|
|
} |
|
|
|
.stf__item[class~="--left"]::after { |
|
|
|
right: 0; |
|
|
|
background: linear-gradient(to left, rgba(0,0,0,0.65), transparent); |
|
|
|
} |
|
|
|
.stf__item[class~="--right"]::after { |
|
|
|
left: 0; |
|
|
|
background: linear-gradient(to right, rgba(0,0,0,0.65), transparent); |
|
|
|
} |
|
|
|
`}} /> |
|
|
|
<div |
|
|
|
ref={containerRef} |
|
|
|
className="relative shadow-2xl" |
|
|
|
style={{ |
|
|
|
width: 'min(95vw, 1400px)', |
|
|
|
height: 'min(85vh, 900px)', |
|
|
|
transformStyle: 'preserve-3d', |
|
|
|
}} |
|
|
|
/> |
|
|
|
> |
|
|
|
{/* Static left page */} |
|
|
|
{visibleLeft ? ( |
|
|
|
<div className="absolute left-0 top-0 w-1/2 h-full bg-white overflow-hidden flex items-center justify-center"> |
|
|
|
<img src={visibleLeft} className={pageImgClass} alt="" draggable={false} /> |
|
|
|
</div> |
|
|
|
) : ( |
|
|
|
<div |
|
|
|
className="absolute left-0 top-0 w-1/2 h-full overflow-hidden" |
|
|
|
style={{ |
|
|
|
background: 'linear-gradient(135deg, #4a3826 0%, #2e2114 55%, #1a1108 100%)', |
|
|
|
boxShadow: 'inset 0 0 80px rgba(0,0,0,0.55)', |
|
|
|
}} |
|
|
|
aria-hidden |
|
|
|
/> |
|
|
|
)} |
|
|
|
{/* Static right page */} |
|
|
|
{visibleRight ? ( |
|
|
|
<div className="absolute right-0 top-0 w-1/2 h-full bg-white overflow-hidden flex items-center justify-center"> |
|
|
|
<img src={visibleRight} className={pageImgClass} alt="" draggable={false} /> |
|
|
|
</div> |
|
|
|
) : ( |
|
|
|
<div |
|
|
|
className="absolute right-0 top-0 w-1/2 h-full overflow-hidden" |
|
|
|
style={{ |
|
|
|
background: 'linear-gradient(225deg, #4a3826 0%, #2e2114 55%, #1a1108 100%)', |
|
|
|
boxShadow: 'inset 0 0 80px rgba(0,0,0,0.55)', |
|
|
|
}} |
|
|
|
aria-hidden |
|
|
|
/> |
|
|
|
)} |
|
|
|
|
|
|
|
{/* Flipping overlay — forward (right page rotates left) */} |
|
|
|
{flipping === 'forward' && ( |
|
|
|
<div |
|
|
|
className="absolute right-0 top-0 w-1/2 h-full" |
|
|
|
style={{ |
|
|
|
transformOrigin: 'left center', |
|
|
|
transformStyle: 'preserve-3d', |
|
|
|
animation: 'flipBookForward 700ms ease-in-out forwards', |
|
|
|
zIndex: 20, |
|
|
|
}} |
|
|
|
> |
|
|
|
<div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden' }}> |
|
|
|
{getPage(rightIdx) && <img src={getPage(rightIdx)} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</div> |
|
|
|
<div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> |
|
|
|
{getPage(nextLeftIdx) && <img src={getPage(nextLeftIdx)} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
|
|
|
|
{/* Flipping overlay — backward (left page rotates right) */} |
|
|
|
{flipping === 'backward' && ( |
|
|
|
<div |
|
|
|
className="absolute left-0 top-0 w-1/2 h-full" |
|
|
|
style={{ |
|
|
|
transformOrigin: 'right center', |
|
|
|
transformStyle: 'preserve-3d', |
|
|
|
animation: 'flipBookBackward 700ms ease-in-out forwards', |
|
|
|
zIndex: 20, |
|
|
|
}} |
|
|
|
> |
|
|
|
<div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden' }}> |
|
|
|
{getPage(leftIdx) && <img src={getPage(leftIdx)} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</div> |
|
|
|
<div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> |
|
|
|
{getPage(prevRightIdx) && <img src={getPage(prevRightIdx)} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
|
|
|
|
<button |
|
|
|
onClick={goPrev} |
|
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors" |
|
|
|
disabled={spread === 0 || flipping !== null} |
|
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors" |
|
|
|
title="Pagina precedente" |
|
|
|
aria-label="Pagina precedente" |
|
|
|
>‹</button> |
|
|
|
<button |
|
|
|
onClick={goNext} |
|
|
|
className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors" |
|
|
|
disabled={spread >= totalSpreads - 1 || flipping !== null} |
|
|
|
className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors" |
|
|
|
title="Pagina successiva" |
|
|
|
aria-label="Pagina successiva" |
|
|
|
>›</button> |
|
|
|
|
|
|
|
<div className="absolute bottom-5 left-1/2 -translate-x-1/2 bg-white/15 text-white px-4 py-1.5 rounded-full text-sm font-medium z-30"> |
|
|
|
{pageCount === 0 ? 'Nessuna pagina' : `${currentPage + 1} / ${pageCount}`} |
|
|
|
{pages.length === 0 ? 'Nessuna pagina' : indicatorLabel} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
); |
|
|
|
|