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:
2025-11-28 09:08:28 +01:00
parent 0b4a9441d0
commit 65ad1f6cfa
7 changed files with 151 additions and 87 deletions
+10 -7
View File
@@ -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>
+7 -69
View File
@@ -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]);
+117
View File
@@ -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;
+8 -4
View File
@@ -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'));
};
+1 -1
View File
@@ -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'] {
+5 -4
View File
@@ -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 {
+3 -2
View File
@@ -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;
}
}