import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { clamp } from '../utils/math'; import PdfViewer from '../preview/PdfViewer'; type DocumentLike = { id?: string | number; title?: string; content_type?: string | null; [key: string]: unknown; }; interface PreviewZoomOverlayProps { open?: boolean; onClose?: () => void; document?: DocumentLike | null; } type NaturalSize = { width: number | null; height: number | null }; type FocusPoint = { xRatio: number; yRatio: number } | null; type DisplayKind = 'image' | 'pdf'; type DocumentLink = { url: string; alt?: string; contentType?: string | null; }; type DocumentLikeWithPreview = DocumentLike & { documentLink?: DocumentLink }; const determineDisplayKind = (entry?: DocumentLink | null): DisplayKind => { const type = entry?.contentType?.toLowerCase?.() || ''; if (type.includes('pdf')) { return 'pdf'; } if (type.startsWith('image/')) { return 'image'; } const url = entry?.url?.toLowerCase?.() || ''; if (url.endsWith('.pdf')) { return 'pdf'; } return 'image'; }; const noop = () => {}; const PreviewZoomOverlay: React.FC = ({ open = false, onClose = noop, document: overlayDocument = null, }) => { const portalTarget = document.body; const [isNativeScale, setIsNativeScale] = useState(false); const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); const [renderBackdrop, setRenderBackdrop] = useState(false); const [isBackdropVisible, setBackdropVisible] = useState(false); const [documentSnapshot, setDocumentSnapshot] = useState(null); const scrollRef = useRef(null); const mediaRef = useRef(null); const focusRef = useRef(null); const previouslyFocusedRef = useRef(null); const visibilityTimerRef = useRef(null); const displayTimerRef = useRef(null); const currentDocument = overlayDocument as DocumentLikeWithPreview | null; useEffect(() => { if (currentDocument?.documentLink?.url) { setDocumentSnapshot(currentDocument); } }, [currentDocument]); const activeDocument = open && currentDocument?.documentLink?.url ? currentDocument : documentSnapshot; const documentLink = activeDocument?.documentLink || null; const displayKind = useMemo(() => determineDisplayKind(documentLink), [documentLink]); const isPdfDisplay = displayKind === 'pdf'; const documentTitle = activeDocument?.title || undefined; const effectiveAlt = documentLink?.alt || documentTitle || 'Document preview'; useEffect(() => { if (visibilityTimerRef.current) { clearTimeout(visibilityTimerRef.current); visibilityTimerRef.current = null; } if (displayTimerRef.current) { cancelAnimationFrame(displayTimerRef.current); displayTimerRef.current = null; } if (open && documentLink?.url) { setRenderBackdrop(true); displayTimerRef.current = requestAnimationFrame(() => { displayTimerRef.current = requestAnimationFrame(() => { setBackdropVisible(true); }); }); return () => { if (displayTimerRef.current) { cancelAnimationFrame(displayTimerRef.current); displayTimerRef.current = null; } }; } setBackdropVisible(false); visibilityTimerRef.current = window.setTimeout(() => { setRenderBackdrop(false); }, 260); return () => { if (visibilityTimerRef.current) { clearTimeout(visibilityTimerRef.current); visibilityTimerRef.current = null; } }; }, [open, documentLink?.url]); useEffect(() => () => { if (visibilityTimerRef.current) { clearTimeout(visibilityTimerRef.current); } if (displayTimerRef.current) { cancelAnimationFrame(displayTimerRef.current); } }, []); useEffect(() => { }, [isPdfDisplay, open, documentLink?.url]); useEffect(() => { setIsNativeScale(false); setNaturalSize({ width: null, height: null }); focusRef.current = null; const scrollEl = scrollRef.current; if (scrollEl) { scrollEl.scrollLeft = 0; scrollEl.scrollTop = 0; } }, [open, documentLink?.url, displayKind]); useEffect(() => { if (!open || !isNativeScale) { return; } const scrollEl = scrollRef.current; const mediaEl = mediaRef.current; if (!scrollEl || !mediaEl) { return; } const imageWidth = naturalSize.width || mediaEl.clientWidth; const imageHeight = naturalSize.height || mediaEl.clientHeight; if (!(imageWidth > 0 && imageHeight > 0)) { return; } const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 }; const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth); const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight); const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2; const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2; scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft); scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop); }, [open, isNativeScale, naturalSize.width, naturalSize.height]); useEffect(() => { if (!open) { previouslyFocusedRef.current?.focus?.(); previouslyFocusedRef.current = null; return; } const active = document.activeElement; previouslyFocusedRef.current = active instanceof HTMLElement ? active : null; }, [open]); useEffect(() => { if (!documentLink?.url) { return; } const scrollEl = scrollRef.current; if (scrollEl) { scrollEl.scrollLeft = 0; scrollEl.scrollTop = 0; } focusRef.current = null; }, [documentLink?.url]); useEffect(() => { if (!renderBackdrop || !documentLink?.url) { return undefined; } const frame = requestAnimationFrame(() => { scrollRef.current?.focus?.({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [renderBackdrop, documentLink?.url]); const handleKeyDown = (event: React.KeyboardEvent) => { event.stopPropagation(); if (!open) { return; } const key = event.key; if (key === ' ' || key === 'Space' || key === 'Spacebar') { const target = event.target; if (target instanceof HTMLElement) { const tag = target.tagName ? target.tagName.toLowerCase() : ''; if ( target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select' ) { return; } } event.preventDefault(); onClose(); return; } if (key === 'Escape') { event.preventDefault(); onClose(); return; } }; const toggleZoomAtPoint = (clientX: number, clientY: number) => { if (isPdfDisplay) { return; } const media = mediaRef.current; setIsNativeScale((current) => { if (!current && media) { const rect = media.getBoundingClientRect(); const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5; const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5; focusRef.current = { xRatio: clamp(xRatio, 0, 1), yRatio: clamp(yRatio, 0, 1), }; } else { focusRef.current = null; } return !current; }); }; const handleContentClick = (event: React.MouseEvent) => { if (isPdfDisplay) { return; } event.stopPropagation(); toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); }; const shouldRender = renderBackdrop && Boolean(documentLink?.url); const stageClassName = isPdfDisplay ? 'preview-zoom__stage preview-zoom__stage--pdf' : 'preview-zoom__stage'; const containerClassName = [ 'preview-zoom__scroll', isNativeScale ? 'preview-zoom__scroll--native' : null, isPdfDisplay ? 'preview-zoom__scroll--pdf' : null, ] .filter(Boolean) .join(' '); const backdropClassName = [ 'preview-zoom-backdrop', isBackdropVisible ? 'preview-zoom-backdrop--visible' : '', ] .filter(Boolean) .join(' '); const contentStyle: CSSProperties = isNativeScale ? { cursor: 'zoom-out', width: naturalSize.width ? `${naturalSize.width}px` : 'auto', height: naturalSize.height ? `${naturalSize.height}px` : 'auto', maxWidth: 'none', maxHeight: 'none', touchAction: 'manipulation', } : { cursor: 'zoom-in', maxWidth: '95vw', maxHeight: '95vh', touchAction: 'manipulation', }; if (!shouldRender) { return null; } const effectiveDisplay = documentLink; return createPortal( (
{ if (event.target === event.currentTarget) { onClose(); } }} >
{isPdfDisplay ? (
) : ( {effectiveAlt} { mediaRef.current = node; }} draggable={false} onLoad={(event) => { setNaturalSize({ width: event.currentTarget.naturalWidth || null, height: event.currentTarget.naturalHeight || null, }); }} onClick={handleContentClick} style={contentStyle} /> )}
), portalTarget, ); }; export default PreviewZoomOverlay;