From 65ad1f6cfa7749dd49662857a163064946c18c08 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 28 Nov 2025 09:08:28 +0100 Subject: [PATCH] feat: Introduce `MediaViewer` component to centralize media rendering and refactor document preview and zoom overlay to use it, along with new CSS padding variables. --- frontend/src/detail/PreviewZoomOverlay.tsx | 17 +-- frontend/src/preview/DocumentViewerLayout.tsx | 76 ++---------- frontend/src/preview/MediaViewer.tsx | 117 ++++++++++++++++++ frontend/src/preview/PdfViewer.tsx | 12 +- frontend/src/styles/base/theme.css | 2 +- frontend/src/styles/documents/viewer.css | 9 +- frontend/src/styles/preview/preview-zoom.css | 5 +- 7 files changed, 151 insertions(+), 87 deletions(-) create mode 100644 frontend/src/preview/MediaViewer.tsx diff --git a/frontend/src/detail/PreviewZoomOverlay.tsx b/frontend/src/detail/PreviewZoomOverlay.tsx index a7b87d0..8211539 100644 --- a/frontend/src/detail/PreviewZoomOverlay.tsx +++ b/frontend/src/detail/PreviewZoomOverlay.tsx @@ -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 = ({ } : { 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 = ({ /> ) : ( - {effectiveAlt} { - 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} /> )} diff --git a/frontend/src/preview/DocumentViewerLayout.tsx b/frontend/src/preview/DocumentViewerLayout.tsx index bc87faf..652e21e 100644 --- a/frontend/src/preview/DocumentViewerLayout.tsx +++ b/frontend/src/preview/DocumentViewerLayout.tsx @@ -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 ( - {`Preview - ); - } if (isPdf) { const documentTitle = document.title @@ -115,55 +95,13 @@ const DocumentViewerLayout = ({ ); } - if (isAudio) { - return ( - - ); - } - - if (isVideo) { - return ( - - ); - } - - const displayMimeType = document.mime_type || documentLink.mimeType || 'this file type'; - const displayFilename = documentLink.filename - || document.filename - || document.original_name - || 'download'; - return ( -
-
- Preview is not available for {displayMimeType} files. -
-
{displayFilename}
- - - Download - -
+ ); }, [document, documentLink, viewportRef]); diff --git a/frontend/src/preview/MediaViewer.tsx b/frontend/src/preview/MediaViewer.tsx new file mode 100644 index 0000000..e6f288f --- /dev/null +++ b/frontend/src/preview/MediaViewer.tsx @@ -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) => void; + onClick?: (event: React.MouseEvent) => void; + mediaRef?: React.Ref; + 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 = ({ + 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 ( + {alt} + ); + } + + if (isAudio) { + return ( +