feat(ui/file-upload): show inline previews of text, pdf and video files (#15182)

This commit is contained in:
Purnima Garg 2025-11-04 22:00:55 +05:30 committed by GitHub
parent 3d7d7a6eed
commit b0673a67d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 211 additions and 34 deletions

View File

@ -71,7 +71,7 @@ export const EditorContainer = styled.div<{ $readOnly?: boolean; $hideBorder?: b
flex: 1 1 100%;
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
}
.remirror-editor.ProseMirror {

View File

@ -306,7 +306,7 @@ class FileDragDropExtension extends NodeExtension<FileDragDropOptions> {
const url = node.getAttribute(FILE_ATTRS.url) || '';
const name = node.getAttribute(FILE_ATTRS.name) || '';
const type = node.getAttribute(FILE_ATTRS.type) || '';
const type = node.getAttribute(FILE_ATTRS.type) || getFileTypeFromUrl(url) || '';
const size = parseInt(node.getAttribute(FILE_ATTRS.size) || '0', 10);
const id = node.getAttribute(FILE_ATTRS.id) || '';

View File

@ -1,13 +1,17 @@
import { NodeViewComponentProps } from '@remirror/react';
import { Typography } from 'antd';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import styled from 'styled-components';
import { Button } from '@components/components/Button';
import {
FILE_ATTRS,
FILE_TYPES_TO_PREVIEW,
FileNodeAttributes,
getExtensionFromFileName,
getFileIconFromExtension,
getFileTypeFromFilename,
handleFileDownload,
} from '@components/components/Editor/extensions/fileDragDrop/fileUtils';
import { Icon } from '@components/components/Icon';
@ -15,31 +19,41 @@ import { colors } from '@components/theme';
import Loading from '@app/shared/Loading';
const FileContainer = styled.span`
width: fit-content;
const StyledIcon = styled(Icon)`
flex-shrink: 0;
`;
const FileContainer = styled.div<{ $isInline?: boolean }>`
display: inline-block;
padding: 4px;
${(props) =>
props.$isInline
? `
width: fit-content;
.ProseMirror-selectednode & {
border-radius: 8px;
background-color: ${colors.gray[1500]};
}
`
: `
max-width: 100%;
width: 100%;
`}
cursor: pointer;
color: ${({ theme }) => theme.styles['primary-color']};
:hover {
border-radius: 8px;
background-color: ${colors.gray[1500]};
}
.ProseMirror-selectednode > & {
border-radius: 8px;
background-color: ${colors.gray[1500]};
}
`;
const FileDetails = styled.span`
width: fit-content;
max-width: 350px;
display: flex;
gap: 4px;
align-items: center;
font-weight: 600;
max-width: 350px;
width: max-content;
padding: 4px;
`;
const FileName = styled(Typography.Text)`
@ -49,6 +63,57 @@ const FileName = styled(Typography.Text)`
text-overflow: ellipsis;
`;
const StyledSyntaxHighlighter = styled(SyntaxHighlighter)`
background-color: ${colors.gray[1500]} !important;
border: none !important;
`;
const PdfWrapper = styled.div`
resize: both;
overflow: hidden;
width: 100%;
max-width: 100%;
height: 400px;
border-radius: 8px;
`;
const PdfViewer = styled.iframe<{ $isResizing?: boolean }>`
width: 100%;
height: 100%;
border: none;
pointer-events: ${({ $isResizing }) => ($isResizing ? 'none' : 'auto')};
border-radius: 8px;
margin-top: 8px;
`;
const VideoContainer = styled.div`
border-radius: 8px;
overflow: hidden;
resize: horizontal;
min-width: 150px;
max-width: 100%;
width: 50%;
background-color: ${colors.black};
margin-top: 8px;
`;
const VideoPlayer = styled.video`
width: 100%;
height: auto;
border-radius: 8px;
`;
const FileNameButtonWrapper = styled.div`
display: flex;
align-items: center;
width: fit-content;
:hover {
border-radius: 8px;
background-color: ${colors.gray[1500]};
}
`;
interface FileNodeViewProps extends NodeViewComponentProps {
node: {
attrs: FileNodeAttributes;
@ -57,7 +122,14 @@ interface FileNodeViewProps extends NodeViewComponentProps {
}
export const FileNodeView: React.FC<FileNodeViewProps> = ({ node, onFileDownloadView }) => {
const [isPreviewVisible, setIsPreviewVisible] = useState(true);
const { url, name, type, size, id } = node.attrs;
const extension = getExtensionFromFileName(name);
const fileType = type || getFileTypeFromFilename(name);
const icon = getFileIconFromExtension(extension || '');
const shouldWrap = extension === 'txt';
const isPdf = fileType === 'application/pdf';
const isVideo = fileType.startsWith('video/');
// Create props with data attributes for markdown conversion
// These must match exactly what toDOM creates in the extension
@ -65,15 +137,35 @@ export const FileNodeView: React.FC<FileNodeViewProps> = ({ node, onFileDownload
className: 'file-node',
[FILE_ATTRS.url]: url,
[FILE_ATTRS.name]: name,
[FILE_ATTRS.type]: type,
[FILE_ATTRS.type]: fileType,
[FILE_ATTRS.size]: size.toString(),
[FILE_ATTRS.id]: id,
};
const [fileContent, setFileContent] = useState<string | null>(null);
const [pdfError, setPdfError] = useState(false);
const [videoError, setVideoError] = useState(false);
const [isResizingPdf, setIsResizingPdf] = useState(false);
useEffect(() => {
if (!url) return;
const shouldShowPreview = FILE_TYPES_TO_PREVIEW.some((t) => fileType?.startsWith(t));
if (shouldShowPreview) {
fetch(url)
.then((res) => res.text())
.then(setFileContent)
.catch(() => setFileContent('Could not load file.'));
} else {
setFileContent(null);
}
}, [url, fileType]);
// Show loading state if no URL yet (file is being uploaded)
if (!url) {
return (
<FileContainer {...containerProps}>
<FileContainer {...containerProps} $isInline>
<FileDetails>
<Loading height={18} width={20} marginTop={0} />
<FileName>Uploading {name}...</FileName>
@ -82,22 +174,105 @@ export const FileNodeView: React.FC<FileNodeViewProps> = ({ node, onFileDownload
);
}
const extension = getExtensionFromFileName(name);
const icon = getFileIconFromExtension(extension || '');
const fileNode = (
<FileDetails
onClick={(e) => {
e.stopPropagation();
// Track file download/view event
onFileDownloadView?.(fileType, size);
handleFileDownload(url, name);
}}
>
<StyledIcon icon={icon} size="lg" source="phosphor" />
<FileName ellipsis={{ tooltip: name }}>{name}</FileName>
</FileDetails>
);
// Preview pdf files
if (isPdf && !pdfError) {
return (
<FileContainer {...containerProps}>
<FileNameButtonWrapper>
{fileNode}
<Button
icon={{ source: 'phosphor', icon: isPreviewVisible ? 'CaretDown' : 'CaretUp' }}
variant="text"
onClick={() => setIsPreviewVisible(!isPreviewVisible)}
/>
</FileNameButtonWrapper>
{isPreviewVisible && (
<PdfWrapper
onMouseDown={() => setIsResizingPdf(true)}
onMouseUp={() => setIsResizingPdf(false)}
onMouseLeave={() => setIsResizingPdf(false)}
>
<PdfViewer
src={url}
title={name}
onError={() => setPdfError(true)}
$isResizing={isResizingPdf}
/>
</PdfWrapper>
)}
</FileContainer>
);
}
// Preview video files
if (isVideo && !videoError) {
return (
<FileContainer {...containerProps}>
<FileNameButtonWrapper>
{fileNode}
<Button
icon={{ source: 'phosphor', icon: isPreviewVisible ? 'CaretDown' : 'CaretUp' }}
variant="text"
onClick={() => setIsPreviewVisible(!isPreviewVisible)}
/>
</FileNameButtonWrapper>
{isPreviewVisible && (
<VideoContainer>
<VideoPlayer controls preload="metadata" onError={() => setVideoError(true)}>
<source src={url} type={fileType} />
</VideoPlayer>
</VideoContainer>
)}
</FileContainer>
);
}
// Preview text files
if (fileContent !== null) {
return (
<FileContainer {...containerProps}>
<FileNameButtonWrapper>
{fileNode}
<Button
icon={{ source: 'phosphor', icon: isPreviewVisible ? 'CaretDown' : 'CaretUp' }}
variant="text"
onClick={() => setIsPreviewVisible(!isPreviewVisible)}
/>
</FileNameButtonWrapper>
{isPreviewVisible && (
<StyledSyntaxHighlighter
language={extension || 'text'}
customStyle={{
maxHeight: 250,
borderRadius: 8,
}}
wrapLongLines={shouldWrap}
>
{fileContent}
</StyledSyntaxHighlighter>
)}
</FileContainer>
);
}
// Other files
return (
<FileContainer {...containerProps}>
<FileDetails
onClick={(e) => {
e.stopPropagation();
// Track file download/view event
onFileDownloadView?.(type, size);
handleFileDownload(url, name);
}}
>
<Icon icon={icon} size="lg" source="phosphor" />
<FileName ellipsis={{ tooltip: name }}>{name}</FileName>
</FileDetails>
<FileContainer {...containerProps} $isInline>
{fileNode}
</FileContainer>
);
};

View File

@ -105,6 +105,8 @@ const EXTENSION_TO_FILE_TYPE = {
sh: 'application/x-sh',
};
export const FILE_TYPES_TO_PREVIEW = ['text/', 'application/json'];
/**
* Generate a unique ID for file nodes
*/