374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
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;
|
|
mime_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;
|
|
mimeType?: string | null;
|
|
};
|
|
|
|
type DocumentLikeWithPreview = DocumentLike & { documentLink?: DocumentLink };
|
|
|
|
const determineDisplayKind = (entry?: DocumentLink | null): DisplayKind => {
|
|
const type = entry?.mimeType?.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<PreviewZoomOverlayProps> = ({
|
|
open = false,
|
|
onClose = noop,
|
|
document: overlayDocument = null,
|
|
}) => {
|
|
const portalTarget = document.body;
|
|
const [isNativeScale, setIsNativeScale] = useState(false);
|
|
const [naturalSize, setNaturalSize] = useState<NaturalSize>({ width: null, height: null });
|
|
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
|
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
|
const [documentSnapshot, setDocumentSnapshot] = useState<DocumentLikeWithPreview | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const mediaRef = useRef<HTMLImageElement | null>(null);
|
|
const focusRef = useRef<FocusPoint>(null);
|
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
const visibilityTimerRef = useRef<number | null>(null);
|
|
const displayTimerRef = useRef<number | null>(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<HTMLDivElement>) => {
|
|
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<HTMLElement>) => {
|
|
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(
|
|
(
|
|
<div
|
|
className={backdropClassName}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Enlarged document preview"
|
|
onClick={onClose}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<div
|
|
className={stageClassName}
|
|
onClick={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
className={containerClassName}
|
|
ref={scrollRef}
|
|
tabIndex={-1}
|
|
>
|
|
{isPdfDisplay ? (
|
|
<div className="preview-zoom__pdf">
|
|
<PdfViewer
|
|
src={effectiveDisplay.url}
|
|
title={effectiveAlt}
|
|
className="preview-zoom__pdf-viewer"
|
|
viewportRef={scrollRef}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<img
|
|
src={effectiveDisplay.url}
|
|
alt={effectiveAlt}
|
|
className="preview-zoom__image"
|
|
ref={(node) => {
|
|
mediaRef.current = node;
|
|
}}
|
|
draggable={false}
|
|
onLoad={(event) => {
|
|
setNaturalSize({
|
|
width: event.currentTarget.naturalWidth || null,
|
|
height: event.currentTarget.naturalHeight || null,
|
|
});
|
|
}}
|
|
onClick={handleContentClick}
|
|
style={contentStyle}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
),
|
|
portalTarget,
|
|
);
|
|
};
|
|
|
|
export default PreviewZoomOverlay;
|