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 { createPortal } from 'react-dom';
import { clamp } from '../utils/math'; import { clamp } from '../utils/math';
import PdfViewer from '../preview/PdfViewer'; import PdfViewer from '../preview/PdfViewer';
import MediaViewer from '../preview/MediaViewer';
import type { Document } from '../types/documents'; import type { Document } from '../types/documents';
@@ -295,8 +296,8 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
} }
: { : {
cursor: 'zoom-in', cursor: 'zoom-in',
maxWidth: '95vw', maxWidth: 'calc(100vw - 2 * var(--preview-padding, 1.5vmin))',
maxHeight: '95vh', maxHeight: 'calc(100vh - 2 * var(--preview-padding, 1.5vmin))',
touchAction: 'manipulation', touchAction: 'manipulation',
}; };
@@ -339,22 +340,24 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
/> />
</div> </div>
) : ( ) : (
<img <MediaViewer
src={effectiveDisplay.url} src={effectiveDisplay.url}
alt={effectiveAlt} alt={effectiveAlt}
className="preview-zoom__image" className="preview-zoom__image"
ref={(node) => { mediaRef={(node: HTMLElement | null) => {
mediaRef.current = node; mediaRef.current = node as HTMLImageElement;
}} }}
draggable={false} draggable={false}
onLoad={(event) => { onLoad={(event) => {
const target = event.currentTarget as HTMLImageElement;
setNaturalSize({ setNaturalSize({
width: event.currentTarget.naturalWidth || null, width: target.naturalWidth || null,
height: event.currentTarget.naturalHeight || null, height: target.naturalHeight || null,
}); });
}} }}
onClick={handleContentClick} onClick={handleContentClick}
style={contentStyle} style={contentStyle}
mimeType={effectiveDisplay.mimeType || undefined}
/> />
)} )}
</div> </div>
+7 -69
View File
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useRef } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import type { JSX } from 'react'; import type { JSX } from 'react';
import DocumentInfoPanel from '../documents/DocumentInfoPanel'; import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons';
import PdfViewer from './PdfViewer'; import PdfViewer from './PdfViewer';
import MediaViewer from './MediaViewer';
import { AUDIO_EXTENSIONS, VIDEO_EXTENSIONS } from '../constants/preview'; import { AUDIO_EXTENSIONS, VIDEO_EXTENSIONS } from '../constants/preview';
import type { Document } from '../types/documents'; import type { Document } from '../types/documents';
@@ -79,28 +79,8 @@ const DocumentViewerLayout = ({
|| document.filename || document.filename
|| document.original_name || document.original_name
|| ''; || '';
const fileExtension = getFileExtension(normalizedFilename);
const isImage = normalizedMimeType.startsWith('image/');
const isPdf = normalizedMimeType === 'application/pdf' const isPdf = normalizedMimeType === 'application/pdf'
|| normalizedMimeType === 'application/x-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) { if (isPdf) {
const documentTitle = document.title 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 ( return (
<div className="document-viewer__unsupported"> <MediaViewer
<div className="document-viewer__unsupported-message"> src={documentLink.url}
Preview is not available for {displayMimeType} files. mimeType={normalizedMimeType}
</div> filename={normalizedFilename}
<div className="document-viewer__unsupported-filename">{displayFilename}</div> alt={`Preview of ${document.title}`}
<a />
className="button-link document-viewer__unsupported-download"
href={documentLink.url}
download={displayFilename}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
<span>Download</span>
</a>
</div>
); );
}, [document, documentLink, viewportRef]); }, [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 attach = () => {
const stackElement = containerRef.current; const stackElement = containerRef.current;
const viewportElement = viewportRef?.current; const viewportElement = viewportRef?.current;
if (!stackElement || !viewportElement) { const sizingElement = stackElement?.parentElement;
if (!stackElement || !sizingElement) {
frameId = requestAnimationFrame(attach); frameId = requestAnimationFrame(attach);
return; return;
} }
const updateBounds = () => { const updateBounds = () => {
const { width, height } = getAvailableViewportSize(viewportElement, stackElement); const { width, height } = getAvailableViewportSize(sizingElement, stackElement);
const nextWidth = Math.max(1, Math.round(width || 0)); const nextWidth = Math.max(1, Math.round(width || 0));
const nextHeight = Math.max(1, Math.round(height || 0)); const nextHeight = Math.max(1, Math.round(height || 0));
setViewportWidth((prev) => (prev === nextWidth ? prev : nextWidth)); setViewportWidth((prev) => (prev === nextWidth ? prev : nextWidth));
@@ -201,8 +203,10 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.
}; };
observeNode(stackElement); observeNode(stackElement);
observeNode(stackElement.parentElement); observeNode(sizingElement);
observeNode(viewportElement); if (viewportElement) {
observeNode(viewportElement);
}
observeNode(stackElement.closest('.document-viewer__viewport')); observeNode(stackElement.closest('.document-viewer__viewport'));
}; };
+1 -1
View File
@@ -111,7 +111,7 @@
--detail-panel-width: calc(100vw / 3); --detail-panel-width: calc(100vw / 3);
--sidebar-width: 20em; --sidebar-width: 20em;
--documents-grid-title-size: 0.8rem; --documents-grid-title-size: 0.8rem;
--media-viewer-padding: 0.75rem;
} }
:root[data-theme='light'] { :root[data-theme='light'] {
+5 -4
View File
@@ -382,7 +382,9 @@
max-height: 100%; max-height: 100%;
grid-area: viewport; grid-area: viewport;
background: var(--surface-subtle); 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 { .document-viewer__object {
@@ -458,7 +460,6 @@
align-items: center; align-items: center;
--pdf-viewer-viewport-width: 100%; --pdf-viewer-viewport-width: 100%;
--pdf-viewer-viewport-height: 100%; --pdf-viewer-viewport-height: 100%;
padding: var(--pdf-viewer-stack-padding, 0);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -481,9 +482,9 @@
} }
.pdf-viewer__canvas-stack--fit-width .pdf-viewer__page-wrapper--limit-width { .pdf-viewer__canvas-stack--fit-width .pdf-viewer__page-wrapper--limit-width {
width: var(--pdf-viewer-page-width, 100%); width: 100%;
height: auto; 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 { .pdf-viewer__canvas-stack--contain .pdf-viewer__page-wrapper--limit-height {
+2 -1
View File
@@ -26,6 +26,7 @@
max-width: 99vw; max-width: 99vw;
max-height: 99vh; max-height: 99vh;
z-index: 3000000; z-index: 3000000;
--preview-padding: 1.5vmin;
} }
.preview-zoom__stage--pdf { .preview-zoom__stage--pdf {
@@ -61,7 +62,7 @@
overflow: auto; overflow: auto;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
--pdf-viewer-stack-padding: 1vh; --pdf-viewer-stack-padding: var(--preview-padding);
} }
.preview-zoom__scroll:focus { .preview-zoom__scroll:focus {