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 { 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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 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'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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'] {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user