feat: Introduce MediaViewer component to centralize media rendering and refactor document preview and zoom overlay to use it, along with new CSS padding variables.
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'reac
|
||||
import { createPortal } from 'react-dom';
|
||||
import { clamp } from '../utils/math';
|
||||
import PdfViewer from '../preview/PdfViewer';
|
||||
import MediaViewer from '../preview/MediaViewer';
|
||||
|
||||
import type { Document } from '../types/documents';
|
||||
|
||||
@@ -295,8 +296,8 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
||||
}
|
||||
: {
|
||||
cursor: 'zoom-in',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
maxWidth: 'calc(100vw - 2 * var(--preview-padding, 1.5vmin))',
|
||||
maxHeight: 'calc(100vh - 2 * var(--preview-padding, 1.5vmin))',
|
||||
touchAction: 'manipulation',
|
||||
};
|
||||
|
||||
@@ -339,22 +340,24 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
<MediaViewer
|
||||
src={effectiveDisplay.url}
|
||||
alt={effectiveAlt}
|
||||
className="preview-zoom__image"
|
||||
ref={(node) => {
|
||||
mediaRef.current = node;
|
||||
mediaRef={(node: HTMLElement | null) => {
|
||||
mediaRef.current = node as HTMLImageElement;
|
||||
}}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
const target = event.currentTarget as HTMLImageElement;
|
||||
setNaturalSize({
|
||||
width: event.currentTarget.naturalWidth || null,
|
||||
height: event.currentTarget.naturalHeight || null,
|
||||
width: target.naturalWidth || null,
|
||||
height: target.naturalHeight || null,
|
||||
});
|
||||
}}
|
||||
onClick={handleContentClick}
|
||||
style={contentStyle}
|
||||
mimeType={effectiveDisplay.mimeType || undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
||||
import { DownloadIcon } from '../ui/icons';
|
||||
import PdfViewer from './PdfViewer';
|
||||
import MediaViewer from './MediaViewer';
|
||||
import { AUDIO_EXTENSIONS, VIDEO_EXTENSIONS } from '../constants/preview';
|
||||
|
||||
import type { Document } from '../types/documents';
|
||||
@@ -79,28 +79,8 @@ const DocumentViewerLayout = ({
|
||||
|| document.filename
|
||||
|| document.original_name
|
||||
|| '';
|
||||
const fileExtension = getFileExtension(normalizedFilename);
|
||||
const isImage = normalizedMimeType.startsWith('image/');
|
||||
const isPdf = normalizedMimeType === 'application/pdf'
|
||||
|| normalizedMimeType === 'application/x-pdf';
|
||||
const isAudio = normalizedMimeType.startsWith('audio/')
|
||||
|| AUDIO_EXTENSIONS.has(fileExtension);
|
||||
const isVideo = normalizedMimeType.startsWith('video/')
|
||||
|| VIDEO_EXTENSIONS.has(fileExtension);
|
||||
const mediaLabel = document.title
|
||||
|| normalizedFilename
|
||||
|| 'Document preview';
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<img
|
||||
src={documentLink.url}
|
||||
alt={`Preview of ${document.title}`}
|
||||
className="document-viewer__object document-viewer__object--image"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
const documentTitle = document.title
|
||||
@@ -115,55 +95,13 @@ const DocumentViewerLayout = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<audio
|
||||
className="document-viewer__object document-viewer__object--audio"
|
||||
controls
|
||||
preload="metadata"
|
||||
src={documentLink.url}
|
||||
aria-label={`Audio preview of ${mediaLabel}`}
|
||||
>
|
||||
</audio>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
return (
|
||||
<video
|
||||
className="document-viewer__object document-viewer__object--video"
|
||||
controls
|
||||
preload="metadata"
|
||||
src={documentLink.url}
|
||||
aria-label={`Video preview of ${mediaLabel}`}
|
||||
>
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
const displayMimeType = document.mime_type || documentLink.mimeType || 'this file type';
|
||||
const displayFilename = documentLink.filename
|
||||
|| document.filename
|
||||
|| document.original_name
|
||||
|| 'download';
|
||||
|
||||
return (
|
||||
<div className="document-viewer__unsupported">
|
||||
<div className="document-viewer__unsupported-message">
|
||||
Preview is not available for {displayMimeType} files.
|
||||
</div>
|
||||
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
|
||||
<a
|
||||
className="button-link document-viewer__unsupported-download"
|
||||
href={documentLink.url}
|
||||
download={displayFilename}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DownloadIcon />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
</div>
|
||||
<MediaViewer
|
||||
src={documentLink.url}
|
||||
mimeType={normalizedMimeType}
|
||||
filename={normalizedFilename}
|
||||
alt={`Preview of ${document.title}`}
|
||||
/>
|
||||
);
|
||||
}, [document, documentLink, viewportRef]);
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { DownloadIcon } from '../ui/icons';
|
||||
import { AUDIO_EXTENSIONS, VIDEO_EXTENSIONS } from '../constants/preview';
|
||||
|
||||
export interface MediaViewerProps {
|
||||
src: string;
|
||||
mimeType?: string;
|
||||
filename?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onLoad?: (event: React.SyntheticEvent<HTMLElement>) => void;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
mediaRef?: React.Ref<any>;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
const getFileExtension = (filename?: string | null) => {
|
||||
if (!filename) {
|
||||
return '';
|
||||
}
|
||||
const match = filename.toLowerCase().match(/\.([a-z0-9]+)$/);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
|
||||
const MediaViewer: React.FC<MediaViewerProps> = ({
|
||||
src,
|
||||
mimeType = '',
|
||||
filename = '',
|
||||
alt = 'Media preview',
|
||||
className,
|
||||
style,
|
||||
onLoad,
|
||||
onClick,
|
||||
mediaRef,
|
||||
draggable = false,
|
||||
}) => {
|
||||
const normalizedMimeType = mimeType.toLowerCase();
|
||||
const fileExtension = getFileExtension(filename);
|
||||
|
||||
const isImage = normalizedMimeType.startsWith('image/');
|
||||
const isAudio = normalizedMimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(fileExtension);
|
||||
const isVideo = normalizedMimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(fileExtension);
|
||||
|
||||
const viewerClasses = ['document-viewer__object', className].filter(Boolean).join(' ');
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<img
|
||||
ref={mediaRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${viewerClasses} document-viewer__object--image`}
|
||||
style={style}
|
||||
onLoad={onLoad}
|
||||
onClick={onClick}
|
||||
draggable={draggable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<audio
|
||||
ref={mediaRef}
|
||||
className={`${viewerClasses} document-viewer__object--audio`}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={src}
|
||||
aria-label={`Audio preview of ${alt}`}
|
||||
style={style}
|
||||
onLoadedMetadata={onLoad}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
return (
|
||||
<video
|
||||
ref={mediaRef}
|
||||
className={`${viewerClasses} document-viewer__object--video`}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={src}
|
||||
aria-label={`Video preview of ${alt}`}
|
||||
style={style}
|
||||
onLoadedMetadata={onLoad}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayMimeType = mimeType || 'this file type';
|
||||
const displayFilename = filename || 'download';
|
||||
|
||||
return (
|
||||
<div className="document-viewer__unsupported" style={style}>
|
||||
<div className="document-viewer__unsupported-message">
|
||||
Preview is not available for {displayMimeType} files.
|
||||
</div>
|
||||
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
|
||||
<a
|
||||
className="button-link document-viewer__unsupported-download"
|
||||
href={src}
|
||||
download={displayFilename}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DownloadIcon />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaViewer;
|
||||
@@ -172,13 +172,15 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.
|
||||
const attach = () => {
|
||||
const stackElement = containerRef.current;
|
||||
const viewportElement = viewportRef?.current;
|
||||
if (!stackElement || !viewportElement) {
|
||||
const sizingElement = stackElement?.parentElement;
|
||||
|
||||
if (!stackElement || !sizingElement) {
|
||||
frameId = requestAnimationFrame(attach);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateBounds = () => {
|
||||
const { width, height } = getAvailableViewportSize(viewportElement, stackElement);
|
||||
const { width, height } = getAvailableViewportSize(sizingElement, stackElement);
|
||||
const nextWidth = Math.max(1, Math.round(width || 0));
|
||||
const nextHeight = Math.max(1, Math.round(height || 0));
|
||||
setViewportWidth((prev) => (prev === nextWidth ? prev : nextWidth));
|
||||
@@ -201,8 +203,10 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.
|
||||
};
|
||||
|
||||
observeNode(stackElement);
|
||||
observeNode(stackElement.parentElement);
|
||||
observeNode(viewportElement);
|
||||
observeNode(sizingElement);
|
||||
if (viewportElement) {
|
||||
observeNode(viewportElement);
|
||||
}
|
||||
observeNode(stackElement.closest('.document-viewer__viewport'));
|
||||
};
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
--detail-panel-width: calc(100vw / 3);
|
||||
--sidebar-width: 20em;
|
||||
--documents-grid-title-size: 0.8rem;
|
||||
|
||||
--media-viewer-padding: 0.75rem;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
|
||||
@@ -382,7 +382,9 @@
|
||||
max-height: 100%;
|
||||
grid-area: viewport;
|
||||
background: var(--surface-subtle);
|
||||
--pdf-viewer-stack-padding: 0.75rem;
|
||||
padding: 1.5vmin;
|
||||
box-sizing: border-box;
|
||||
--pdf-viewer-stack-padding: 1.5vmin;
|
||||
}
|
||||
|
||||
.document-viewer__object {
|
||||
@@ -458,7 +460,6 @@
|
||||
align-items: center;
|
||||
--pdf-viewer-viewport-width: 100%;
|
||||
--pdf-viewer-viewport-height: 100%;
|
||||
padding: var(--pdf-viewer-stack-padding, 0);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -481,9 +482,9 @@
|
||||
}
|
||||
|
||||
.pdf-viewer__canvas-stack--fit-width .pdf-viewer__page-wrapper--limit-width {
|
||||
width: var(--pdf-viewer-page-width, 100%);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: calc(var(--pdf-viewer-page-width) / var(--pdf-viewer-page-height));
|
||||
aspect-ratio: var(--pdf-viewer-page-aspect, calc(var(--pdf-viewer-page-width) / var(--pdf-viewer-page-height)));
|
||||
}
|
||||
|
||||
.pdf-viewer__canvas-stack--contain .pdf-viewer__page-wrapper--limit-height {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
max-width: 99vw;
|
||||
max-height: 99vh;
|
||||
z-index: 3000000;
|
||||
--preview-padding: 1.5vmin;
|
||||
}
|
||||
|
||||
.preview-zoom__stage--pdf {
|
||||
@@ -61,7 +62,7 @@
|
||||
overflow: auto;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
--pdf-viewer-stack-padding: 1vh;
|
||||
--pdf-viewer-stack-padding: var(--preview-padding);
|
||||
}
|
||||
|
||||
.preview-zoom__scroll:focus {
|
||||
@@ -102,4 +103,4 @@
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user