feat: support preview pdf, image, audio and video

This commit is contained in:
fantasticit 2022-04-01 16:48:07 +08:00
parent b290a2305d
commit 39cf943eaa
11 changed files with 272 additions and 79 deletions

View File

@ -76,6 +76,7 @@
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-pdf": "^5.7.2",
"react-split-pane": "^0.1.92", "react-split-pane": "^0.1.92",
"scroll-into-view-if-needed": "^2.2.29", "scroll-into-view-if-needed": "^2.2.29",
"swr": "^1.2.0", "swr": "^1.2.0",

View File

@ -2,12 +2,13 @@ import { useEffect } from 'react';
import Viewer from 'viewerjs'; import Viewer from 'viewerjs';
interface IProps { interface IProps {
containerSelector: string; containerSelector?: string;
container?: HTMLElement;
} }
export const ImageViewer: React.FC<IProps> = ({ containerSelector }) => { export const ImageViewer: React.FC<IProps> = ({ container, containerSelector }) => {
useEffect(() => { useEffect(() => {
const el = document.querySelector(containerSelector); const el = container || document.querySelector(containerSelector);
if (!el) { if (!el) {
return null; return null;
} }
@ -24,7 +25,7 @@ export const ImageViewer: React.FC<IProps> = ({ containerSelector }) => {
io.disconnect(); io.disconnect();
viewer.destroy(); viewer.destroy();
}; };
}, [containerSelector]); }, [container, containerSelector]);
return null; return null;
}; };

View File

@ -31,11 +31,13 @@ export const normalizeFileSize = (size) => {
return (size / 1024 / 1024).toFixed(2) + ' MB'; return (size / 1024 / 1024).toFixed(2) + ' MB';
}; };
export type FileType = 'image' | 'audio' | 'video' | 'file'; export type FileType = 'image' | 'audio' | 'video' | 'pdf' | 'file';
export const normalizeFileType = (fileType): FileType => { export const normalizeFileType = (fileType): FileType => {
if (!fileType) return 'file'; if (!fileType) return 'file';
if (fileType === 'application/pdf') return 'pdf';
if (fileType.startsWith('image')) { if (fileType.startsWith('image')) {
return 'image'; return 'image';
} }

View File

@ -0,0 +1,28 @@
import { IconFile, IconSong, IconVideo, IconImage } from '@douyinfe/semi-icons';
import { normalizeFileType } from '../../services/file';
export const getFileTypeIcon = (fileType: string) => {
const type = normalizeFileType(fileType);
switch (type) {
case 'audio':
return <IconSong />;
case 'video':
return <IconVideo />;
case 'file':
return <IconFile />;
case 'image':
return <IconImage />;
case 'pdf':
return <IconFile />;
default: {
const value: never = type;
throw new Error(value);
}
}
};

View File

@ -1,12 +1,17 @@
.wrap { .wrap {
border: 1px solid var(--semi-color-border);
border-radius: var(--border-radius);
&.isPreviewing {
&::after {
background-color: transparent !important ;
}
}
> div:first-child {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 16px; padding: 8px 16px;
border: 1px solid var(--semi-color-border);
border-radius: var(--border-radius);
&:hover {
border: 1px solid var(--semi-color-link);
} }
} }

View File

@ -2,51 +2,18 @@ import { useEffect, useRef } from 'react';
import cls from 'classnames'; import cls from 'classnames';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
import { Button, Typography, Spin, Collapsible, Space } from '@douyinfe/semi-ui'; import { Button, Typography, Spin, Collapsible, Space } from '@douyinfe/semi-ui';
import { import { IconDownload, IconPlayCircle, IconClose } from '@douyinfe/semi-icons';
IconDownload,
IconPlayCircle,
IconFile,
IconSong,
IconVideo,
IconImage,
IconClose,
} from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import { download } from '../../services/download'; import { download } from '../../services/download';
import { uploadFile } from 'services/file'; import { uploadFile } from 'services/file';
import { import { normalizeFileSize, extractFileExtension, extractFilename } from '../../services/file';
normalizeFileSize, import { Player } from './player';
extractFileExtension, import { getFileTypeIcon } from './file-icon';
extractFilename,
normalizeFileType,
FileType,
} from '../../services/file';
import styles from './index.module.scss'; import styles from './index.module.scss';
const { Text } = Typography; const { Text } = Typography;
const getFileTypeIcon = (type: FileType) => {
switch (type) {
case 'audio':
return <IconSong />;
case 'video':
return <IconVideo />;
case 'file':
return <IconFile />;
case 'image':
return <IconImage />;
default: {
const value: never = type;
throw new Error(value);
}
}
};
export const AttachmentWrapper = ({ editor, node, updateAttributes }) => { export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
const $upload = useRef<HTMLInputElement>(); const $upload = useRef<HTMLInputElement>();
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
@ -78,8 +45,6 @@ export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
} }
}; };
const type = normalizeFileType(fileType);
useEffect(() => { useEffect(() => {
if (!url && !hasTrigger) { if (!url && !hasTrigger) {
selectFile(); selectFile();
@ -88,7 +53,7 @@ export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
}, [url, hasTrigger]); }, [url, hasTrigger]);
const content = (() => { const content = (() => {
if (error) { if (error !== 'null') {
return ( return (
<div className={cls(styles.wrap, 'render-wrapper')} onClick={selectFile}> <div className={cls(styles.wrap, 'render-wrapper')} onClick={selectFile}>
<Text>{error}</Text> <Text>{error}</Text>
@ -99,17 +64,17 @@ export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
if (url) { if (url) {
return ( return (
<> <>
<div className={cls(styles.wrap, 'render-wrapper')} onClick={selectFile}> <div className={cls(styles.wrap, visible && styles.isPreviewing, 'render-wrapper')} onClick={selectFile}>
<div>
<Space> <Space>
{getFileTypeIcon(type)} {getFileTypeIcon(fileType)}
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth: 320 }}> <Text ellipsis={{ showTooltip: true }} style={{ maxWidth: 320 }}>
{fileName}.{fileExt} {fileName}.{fileExt}
</Text> </Text>
<Text type="tertiary"> ({normalizeFileSize(fileSize)})</Text> <Text type="tertiary"> ({normalizeFileSize(fileSize)})</Text>
</Space> </Space>
<span> <span>
{type === 'video' || type === 'audio' ? ( <Tooltip content={!visible ? '预览' : '收起'}>
<Tooltip content={!visible ? '播放' : '收起'}>
<Button <Button
theme={'borderless'} theme={'borderless'}
type="tertiary" type="tertiary"
@ -117,7 +82,6 @@ export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
onClick={toggleVisible} onClick={toggleVisible}
/> />
</Tooltip> </Tooltip>
) : null}
<Tooltip content="下载"> <Tooltip content="下载">
<Button <Button
theme={'borderless'} theme={'borderless'}
@ -128,13 +92,12 @@ export const AttachmentWrapper = ({ editor, node, updateAttributes }) => {
</Tooltip> </Tooltip>
</span> </span>
</div> </div>
{url ? ( {url ? (
<Collapsible isOpen={visible}> <Collapsible isOpen={visible}>
{type === 'video' && <video controls autoPlay src={url}></video>} <Player fileType={fileType} url={url} />
{type === 'audio' && <audio controls autoPlay src={url}></audio>}
</Collapsible> </Collapsible>
) : null} ) : null}
</div>
</> </>
); );
} }

View File

@ -0,0 +1,12 @@
.playerWrap {
width: 100%;
display: flex;
justify-content: center;
border-top: 1px solid var(--semi-color-border);
padding: 12px;
> video {
width: 100%;
height: 100%;
}
}

View File

@ -0,0 +1,46 @@
import React, { useMemo, useRef } from 'react';
import { Typography } from '@douyinfe/semi-ui';
import { ImageViewer } from 'components/image-viewer';
import {
normalizeFileSize,
extractFileExtension,
extractFilename,
normalizeFileType,
FileType,
} from '../../../services/file';
import { PDFPlayer } from './pdf-player';
import styles from './index.module.scss';
interface IProps {
url: string;
fileType: string;
}
const { Text } = Typography;
export const Player: React.FC<IProps> = ({ url, fileType }) => {
const ref = useRef();
const type = useMemo(() => normalizeFileType(fileType), [fileType]);
const player = useMemo(() => {
if (type === 'video') return <video controls autoPlay src={url}></video>;
if (type === 'audio') return <audio controls autoPlay src={url}></audio>;
if (type === 'image')
return <img style={{ width: 'auto', height: 'auto', maxWidth: '100%', maxHeight: 300 }} src={url} />;
if (type === 'pdf') return <PDFPlayer url={url} />;
return <Text type="tertiary"></Text>;
}, [type, url]);
return (
<>
<div ref={ref} className={styles.playerWrap}>
{player}
</div>
<ImageViewer container={ref.current} />
</>
);
};

View File

@ -0,0 +1,37 @@
.playerWrap {
width: 100%;
:global {
.react-pdf__Document {
display: flex;
flex-direction: column;
align-items: center;
border-radius: 8px;
}
.react-pdf__Page {
display: flex;
justify-content: center;
width: 100%;
height: 420px;
overflow: auto;
}
.react-pdf__Page canvas {
max-width: 100%;
height: auto !important;
}
.react-pdf__message {
padding: 20px;
color: white;
}
}
.paginationWrap {
margin-top: 1em;
text-align: center;
display: flex;
justify-content: center;
}
}

View File

@ -0,0 +1,30 @@
import React, { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { Pagination } from '@douyinfe/semi-ui';
import styles from './index.module.scss';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/legacy/build/pdf.worker.min.js`;
interface IProps {
url: string;
}
export const PDFPlayer: React.FC<IProps> = ({ url }) => {
const [total, setTotal] = useState(1);
const [pageNumber, setPageNumber] = useState(1);
function onDocumentLoadSuccess({ numPages }) {
setTotal(numPages);
}
return (
<div className={styles.playerWrap}>
<Document file={url} onLoadSuccess={onDocumentLoadSuccess}>
<Page pageNumber={pageNumber} />
</Document>
<div className={styles.paginationWrap}>
<Pagination total={total} pageSize={1} onChange={(page) => setPageNumber(page)} size="small"></Pagination>
</div>
</div>
);
};

View File

@ -104,6 +104,7 @@ importers:
react: 17.0.2 react: 17.0.2
react-dom: 17.0.2 react-dom: 17.0.2
react-helmet: ^6.1.0 react-helmet: ^6.1.0
react-pdf: ^5.7.2
react-split-pane: ^0.1.92 react-split-pane: ^0.1.92
scroll-into-view-if-needed: ^2.2.29 scroll-into-view-if-needed: ^2.2.29
swr: ^1.2.0 swr: ^1.2.0
@ -182,6 +183,7 @@ importers:
react: 17.0.2 react: 17.0.2
react-dom: 17.0.2_react@17.0.2 react-dom: 17.0.2_react@17.0.2
react-helmet: 6.1.0_react@17.0.2 react-helmet: 6.1.0_react@17.0.2
react-pdf: 5.7.2_react-dom@17.0.2+react@17.0.2
react-split-pane: 0.1.92_react-dom@17.0.2+react@17.0.2 react-split-pane: 0.1.92_react-dom@17.0.2+react@17.0.2
scroll-into-view-if-needed: 2.2.29 scroll-into-view-if-needed: 2.2.29
swr: 1.2.0_react@17.0.2 swr: 1.2.0_react@17.0.2
@ -3871,6 +3873,16 @@ packages:
flat-cache: 3.0.4 flat-cache: 3.0.4
dev: true dev: true
/file-loader/6.2.0:
resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==}
engines: {node: '>= 10.13.0'}
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
dependencies:
loader-utils: 2.0.0
schema-utils: 3.1.1
dev: false
/file-uri-to-path/2.0.0: /file-uri-to-path/2.0.0:
resolution: {integrity: sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==} resolution: {integrity: sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -5591,6 +5603,10 @@ packages:
sourcemap-codec: 1.4.8 sourcemap-codec: 1.4.8
dev: true dev: true
/make-cancellable-promise/1.1.0:
resolution: {integrity: sha512-X5Opjm2xcZsOLuJ+Bnhb4t5yfu4ehlA3OKEYLtqUchgVzL/QaqW373ZUVxVHKwvJ38cmYuR4rAHD2yUvAIkTPA==}
dev: false
/make-dir/3.1.0: /make-dir/3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -5602,6 +5618,10 @@ packages:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
dev: true dev: true
/make-event-props/1.3.0:
resolution: {integrity: sha512-oWiDZMcVB1/A487251hEWza1xzgCzl6MXxe9aF24l5Bt9N9UEbqTqKumEfuuLhmlhRZYnc+suVvW4vUs8bwO7Q==}
dev: false
/makeerror/1.0.12: /makeerror/1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
dependencies: dependencies:
@ -5696,10 +5716,18 @@ packages:
yargs-parser: 20.2.9 yargs-parser: 20.2.9
dev: true dev: true
/merge-class-names/1.4.2:
resolution: {integrity: sha512-bOl98VzwCGi25Gcn3xKxnR5p/WrhWFQB59MS/aGENcmUc6iSm96yrFDF0XSNurX9qN4LbJm0R9kfvsQ17i8zCw==}
dev: false
/merge-descriptors/1.0.1: /merge-descriptors/1.0.1:
resolution: {integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=} resolution: {integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=}
dev: false dev: false
/merge-refs/1.0.0:
resolution: {integrity: sha512-WZ4S5wqD9FCR9hxkLgvcHJCBxzXzy3VVE6p8W2OzxRzB+hLRlcadGE2bW9xp2KSzk10rvp4y+pwwKO6JQVguMg==}
dev: false
/merge-stream/2.0.0: /merge-stream/2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
dev: true dev: true
@ -6250,6 +6278,15 @@ packages:
resolution: {integrity: sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=} resolution: {integrity: sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=}
dev: false dev: false
/pdfjs-dist/2.12.313:
resolution: {integrity: sha512-1x6iXO4Qnv6Eb+YFdN5JdUzt4pAkxSp3aLAYPX93eQCyg/m7QFzXVWJHJVtoW48CI8HCXju4dSkhQZwoheL5mA==}
peerDependencies:
worker-loader: ^3.0.8
peerDependenciesMeta:
worker-loader:
optional: true
dev: false
/picocolors/0.2.1: /picocolors/0.2.1:
resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==} resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==}
dev: false dev: false
@ -6672,6 +6709,29 @@ packages:
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
dev: false dev: false
/react-pdf/5.7.2_react-dom@17.0.2+react@17.0.2:
resolution: {integrity: sha512-hdDwvf007V0i2rPCqQVS1fa70CXut17SN3laJYlRHzuqcu8sLLjEoeXihty6c0Ev5g1mw31b8OT8EwRw1s8C4g==}
peerDependencies:
react: ^16.3.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.3.0 || ^17.0.0 || ^18.0.0
dependencies:
'@babel/runtime': 7.16.7
file-loader: 6.2.0
make-cancellable-promise: 1.1.0
make-event-props: 1.3.0
merge-class-names: 1.4.2
merge-refs: 1.0.0
pdfjs-dist: 2.12.313
prop-types: 15.8.1
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
tiny-invariant: 1.2.0
tiny-warning: 1.0.3
transitivePeerDependencies:
- webpack
- worker-loader
dev: false
/react-resizable/1.11.1_react-dom@17.0.2+react@17.0.2: /react-resizable/1.11.1_react-dom@17.0.2+react@17.0.2:
resolution: {integrity: sha512-S70gbLaAYqjuAd49utRHibtHLrHXInh7GuOR+6OO6RO6uleQfuBnWmZjRABfqNEx3C3Z6VPLg0/0uOYFrkfu9Q==} resolution: {integrity: sha512-S70gbLaAYqjuAd49utRHibtHLrHXInh7GuOR+6OO6RO6uleQfuBnWmZjRABfqNEx3C3Z6VPLg0/0uOYFrkfu9Q==}
peerDependencies: peerDependencies:
@ -7737,6 +7797,14 @@ packages:
/through/2.3.8: /through/2.3.8:
resolution: {integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=} resolution: {integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=}
/tiny-invariant/1.2.0:
resolution: {integrity: sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==}
dev: false
/tiny-warning/1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
dev: false
/tippy.js/6.3.7: /tippy.js/6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
dependencies: dependencies: