refactor(client): add attachment file type registry (#5353)
Some checks are pending
Build Docker Image / build-and-push (push) Waiting to run
Build Pro Image / build-and-push (push) Waiting to run
E2E / Build (push) Waiting to run
E2E / Core and plugins (push) Blocked by required conditions
E2E / plugin-workflow (push) Blocked by required conditions
E2E / plugin-workflow-approval (push) Blocked by required conditions
E2E / plugin-data-source-main (push) Blocked by required conditions
E2E / Comment on PR (push) Blocked by required conditions
NocoBase FrontEnd Test / frontend-test (18) (push) Waiting to run

* refactor(client): add attachment file type registry

* refactor(client): change api name of attachmentFileTypes

* fix(client): to open file preview in new window if previewer not defined

* fix(client): fix attachmentFileTypes default value

* refactor(client): simplify file previewer code and allow to preview most file by default

* refactor(client): make custom file type has high priority

* refactor(client): add types for upload component api

* fix(client): fix upload component locale
This commit is contained in:
Junyi 2024-10-04 16:49:26 +08:00 committed by GitHub
parent 512df745fa
commit 164847d9a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 185 additions and 168 deletions

View File

@ -800,6 +800,7 @@
"Try again": "重试一下", "Try again": "重试一下",
"Download logs": "下载日志", "Download logs": "下载日志",
"Download": "下载", "Download": "下载",
"File type is not supported for previewing, please download it to preview.": "不支持预览该文件类型,请下载后查看。",
"Click or drag file to this area to upload": "点击或拖拽文件到此区域上传", "Click or drag file to this area to upload": "点击或拖拽文件到此区域上传",
"Support for a single or bulk upload.": "支持单个或批量上传", "Support for a single or bulk upload.": "支持单个或批量上传",
"File size should not exceed {{size}}.": "文件大小不能超过 {{size}}", "File size should not exceed {{size}}.": "文件大小不能超过 {{size}}",

View File

@ -10,7 +10,7 @@
import { DeleteOutlined, DownloadOutlined, InboxOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons'; import { DeleteOutlined, DownloadOutlined, InboxOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons';
import { Field } from '@formily/core'; import { Field } from '@formily/core';
import { connect, mapProps, mapReadPretty, useField } from '@formily/react'; import { connect, mapProps, mapReadPretty, useField } from '@formily/react';
import { Upload as AntdUpload, Button, Modal, Progress, Space, Tooltip } from 'antd'; import { Alert, Upload as AntdUpload, Button, Modal, Progress, Space, Tooltip } from 'antd';
import useUploadStyle from 'antd/es/upload/style'; import useUploadStyle from 'antd/es/upload/style';
import cls from 'classnames'; import cls from 'classnames';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
@ -18,13 +18,14 @@ import filesize from 'filesize';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LightBox from 'react-image-lightbox'; import LightBox from 'react-image-lightbox';
import match from 'mime-match';
import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps'; import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
import { useProps } from '../../hooks/useProps'; import { useProps } from '../../hooks/useProps';
import { import {
FILE_SIZE_LIMIT_DEFAULT, FILE_SIZE_LIMIT_DEFAULT,
isImage, attachmentFileTypes,
isPdf, getThumbnailPlaceholderURL,
normalizeFile, normalizeFile,
toFileList, toFileList,
toValueItem, toValueItem,
@ -34,6 +35,129 @@ import {
import { useStyles } from './style'; import { useStyles } from './style';
import type { ComposedUpload, DraggerProps, DraggerV2Props, UploadProps } from './type'; import type { ComposedUpload, DraggerProps, DraggerV2Props, UploadProps } from './type';
attachmentFileTypes.add({
match(file) {
return match(file.mimetype || file.type, 'image/*');
},
getThumbnailURL(file) {
return file.url ? `${file.url}${file.thumbnailRule || ''}` : URL.createObjectURL(file.originFileObj);
},
Previewer({ index, list, onSwitchIndex }) {
const onDownload = useCallback(
(e) => {
e.preventDefault();
const file = list[index];
saveAs(file.url, `${file.title}${file.extname}`);
},
[index, list],
);
return (
<LightBox
// discourageDownloads={true}
mainSrc={list[index]?.url}
nextSrc={list[(index + 1) % list.length]?.url}
prevSrc={list[(index + list.length - 1) % list.length]?.url}
onCloseRequest={() => onSwitchIndex(null)}
onMovePrevRequest={() => onSwitchIndex((index + list.length - 1) % list.length)}
onMoveNextRequest={() => onSwitchIndex((index + 1) % list.length)}
imageTitle={list[index]?.title}
toolbarButtons={[
<button
key={'preview-img'}
style={{ fontSize: 22, background: 'none', lineHeight: 1 }}
type="button"
aria-label="Download"
title="Download"
className="ril-zoom-in ril__toolbarItemChild ril__builtinButton"
onClick={onDownload}
>
<DownloadOutlined />
</button>,
]}
/>
);
},
});
const iframePreviewSupportedTypes = ['application/pdf', 'audio/*', 'image/*', 'video/*'];
function IframePreviewer({ index, list, onSwitchIndex }) {
const { t } = useTranslation();
const file = list[index];
const onOpen = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
window.open(file.url);
},
[file],
);
const onDownload = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
saveAs(file.url, `${file.title}${file.extname}`);
},
[file],
);
const onClose = useCallback(() => {
onSwitchIndex(null);
}, [onSwitchIndex]);
return (
<Modal
open={index != null}
title={file.title}
onCancel={onClose}
footer={[
<Button key="open" onClick={onOpen}>
{t('Open in new window')}
</Button>,
<Button key="download" onClick={onDownload}>
{t('Download')}
</Button>,
<Button key="close" onClick={onClose}>
{t('Close')}
</Button>,
]}
width={'85vw'}
centered={true}
>
<div
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 256px)',
height: '90vh',
width: '100%',
background: 'white',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
overflowY: 'auto',
}}
>
{iframePreviewSupportedTypes.some((type) => match(file.mimetype || file.extname, type)) ? (
<iframe
src={file.url}
style={{
width: '100%',
maxHeight: '90vh',
flex: '1 1 auto',
border: 'none',
}}
/>
) : (
<Alert
type="warning"
description={t('File type is not supported for previewing, please download it to preview.')}
showIcon
/>
)}
</div>
</Modal>
);
}
function InternalUpload(props: UploadProps) { function InternalUpload(props: UploadProps) {
const { onChange, ...rest } = props; const { onChange, ...rest } = props;
const onFileChange = useCallback( const onFileChange = useCallback(
@ -85,6 +209,13 @@ function useSizeHint(size: number) {
return s !== 0 ? t('File size should not exceed {{size}}.', { size: sizeString }) : ''; return s !== 0 ? t('File size should not exceed {{size}}.', { size: sizeString }) : '';
} }
function DefaultThumbnailPreviewer({ file }) {
const { componentCls: prefixCls } = useStyles();
const { getThumbnailURL = getThumbnailPlaceholderURL } = attachmentFileTypes.getTypeByFile(file) ?? {};
const imageUrl = getThumbnailURL(file);
return <img src={imageUrl} alt={file.title} className={`${prefixCls}-list-item-image`} />;
}
function AttachmentListItem(props) { function AttachmentListItem(props) {
const { file, disabled, onPreview, onDelete: propsOnDelete, readPretty } = props; const { file, disabled, onPreview, onDelete: propsOnDelete, readPretty } = props;
const { componentCls: prefixCls } = useStyles(); const { componentCls: prefixCls } = useStyles();
@ -104,15 +235,11 @@ function AttachmentListItem(props) {
saveAs(file.url, `${file.title}${file.extname}`); saveAs(file.url, `${file.title}${file.extname}`);
}, [file]); }, [file]);
const { ThumbnailPreviewer = DefaultThumbnailPreviewer } = attachmentFileTypes.getTypeByFile(file) ?? {};
const item = [ const item = [
<span key="thumbnail" className={`${prefixCls}-list-item-thumbnail`}> <span key="thumbnail" className={`${prefixCls}-list-item-thumbnail`}>
{file.imageUrl && ( <ThumbnailPreviewer file={file} />
<img
src={`${file.imageUrl}${file.thumbnailRule || ''}`}
alt={file.title}
className={`${prefixCls}-list-item-image`}
/>
)}
</span>, </span>,
<span key="title" className={`${prefixCls}-list-item-name`} title={file.title}> <span key="title" className={`${prefixCls}-list-item-name`} title={file.title}>
{file.status === 'uploading' ? t('Uploading') : file.title} {file.status === 'uploading' ? t('Uploading') : file.title}
@ -166,121 +293,12 @@ function AttachmentListItem(props) {
); );
} }
const PreviewerTypes = [
{
matcher: isImage,
Component({ index, list, onSwitchIndex }) {
const onDownload = useCallback(
(e) => {
e.preventDefault();
const file = list[index];
saveAs(file.url, `${file.title}${file.extname}`);
},
[index, list],
);
return (
<LightBox
// discourageDownloads={true}
mainSrc={list[index]?.imageUrl}
nextSrc={list[(index + 1) % list.length]?.imageUrl}
prevSrc={list[(index + list.length - 1) % list.length]?.imageUrl}
onCloseRequest={() => onSwitchIndex(null)}
onMovePrevRequest={() => onSwitchIndex((index + list.length - 1) % list.length)}
onMoveNextRequest={() => onSwitchIndex((index + 1) % list.length)}
imageTitle={list[index]?.title}
toolbarButtons={[
<button
key={'preview-img'}
style={{ fontSize: 22, background: 'none', lineHeight: 1 }}
type="button"
aria-label="Download"
title="Download"
className="ril-zoom-in ril__toolbarItemChild ril__builtinButton"
onClick={onDownload}
>
<DownloadOutlined />
</button>,
]}
/>
);
},
},
{
matcher: isPdf,
Component({ index, list, onSwitchIndex }) {
const { t } = useTranslation();
const onDownload = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
const file = list[index];
saveAs(file.url, `${file.title}${file.extname}`);
},
[index, list],
);
const onClose = useCallback(() => {
onSwitchIndex(null);
}, [onSwitchIndex]);
return (
<Modal
open={index != null}
title={'PDF - ' + list[index].title}
onCancel={onClose}
footer={[
<Button
key="download"
style={{
textTransform: 'capitalize',
}}
onClick={onDownload}
>
{t('Download')}
</Button>,
<Button key="close" onClick={onClose} style={{ textTransform: 'capitalize' }}>
{t('Close')}
</Button>,
]}
width={'85vw'}
centered={true}
>
<div
style={{
padding: '8px',
maxWidth: '100%',
maxHeight: 'calc(100vh - 256px)',
height: '90vh',
width: '100%',
background: 'white',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
overflowY: 'auto',
}}
>
<iframe
src={list[index].url}
style={{
width: '100%',
maxHeight: '90vh',
flex: '1 1 auto',
}}
/>
</div>
</Modal>
);
},
},
];
function Previewer({ index, onSwitchIndex, list }) { function Previewer({ index, onSwitchIndex, list }) {
if (index == null) { if (index == null) {
return null; return null;
} }
const file = list[index]; const file = list[index];
const { Component } = PreviewerTypes.find((type) => type.matcher(file)) ?? {}; const { Previewer: Component = IframePreviewer } = attachmentFileTypes.getTypeByFile(file) ?? {};
if (!Component) {
return null;
}
return <Component index={index} list={list} onSwitchIndex={onSwitchIndex} />; return <Component index={index} list={list} onSwitchIndex={onSwitchIndex} />;
} }
@ -298,14 +316,7 @@ export function AttachmentList(props) {
const onPreview = useCallback( const onPreview = useCallback(
(file) => { (file) => {
const index = fileList.findIndex((item) => item.id === file.id); const index = fileList.findIndex((item) => item.id === file.id);
const previewType = PreviewerTypes.find((type) => type.matcher(file));
if (previewType) {
setPreview(index); setPreview(index);
} else {
if (file.id) {
saveAs(file.url, `${file.title}${file.extname}`);
}
}
}, },
[fileList], [fileList],
); );

View File

@ -8,3 +8,4 @@
*/ */
export * from './Upload'; export * from './Upload';
export { attachmentFileTypes } from './shared';

View File

@ -11,20 +11,52 @@ import { isArr, isValid, toArr as toArray } from '@formily/shared';
import { UploadFile } from 'antd/es/upload/interface'; import { UploadFile } from 'antd/es/upload/interface';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import match from 'mime-match'; import match from 'mime-match';
import { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useAPIClient } from '../../../api-client'; import { useAPIClient } from '../../../api-client';
import { UNKNOWN_FILE_ICON, UPLOAD_PLACEHOLDER } from './placeholder'; import { UNKNOWN_FILE_ICON, UPLOAD_PLACEHOLDER } from './placeholder';
import type { IUploadProps, UploadProps } from './type'; import type { IUploadProps, UploadProps } from './type';
export const FILE_SIZE_LIMIT_DEFAULT = 1024 * 1024 * 20; export const FILE_SIZE_LIMIT_DEFAULT = 1024 * 1024 * 20;
export const isImage = (file) => { export interface FileModel {
return match(file.mimetype || file.type, 'image/*'); id: number;
}; filename: string;
path: string;
title: string;
url: string;
extname: string;
size: number;
mimetype: string;
}
export const isPdf = (file) => { export interface PreviewerProps {
return match(file.mimetype || file.type, 'application/pdf'); index: number;
}; list: FileModel[];
onSwitchIndex(index): void;
}
export interface AttachmentFileType {
match(file: any): boolean;
getThumbnailURL?(file: any): string;
ThumbnailPreviewer?: React.ComponentType<{ file: FileModel }>;
Previewer?: React.ComponentType<PreviewerProps>;
}
export class AttachmentFileTypes {
types: AttachmentFileType[] = [];
add(type: AttachmentFileType) {
// NOTE: use unshift to make sure the custom type has higher priority
this.types.unshift(type);
}
getTypeByFile(file): Omit<AttachmentFileType, 'match'> {
return this.types.find((type) => type.match(file));
}
}
/**
* @experimental
*/
export const attachmentFileTypes = new AttachmentFileTypes();
const toArr = (value) => { const toArr = (value) => {
if (!isValid(value)) { if (!isValid(value)) {
@ -36,7 +68,7 @@ const toArr = (value) => {
return toArray(value); return toArray(value);
}; };
export const testOpts = (ext: RegExp, options: { exclude?: string[]; include?: string[] }) => { const testOpts = (ext: RegExp, options: { exclude?: string[]; include?: string[] }) => {
if (options && isArr(options.include)) { if (options && isArr(options.include)) {
return options.include.some((url) => ext.test(url)); return options.include.some((url) => ext.test(url));
} }
@ -48,26 +80,19 @@ export const testOpts = (ext: RegExp, options: { exclude?: string[]; include?: s
return true; return true;
}; };
export const getImageByUrl = (url: string, options: any = {}) => { export function getThumbnailPlaceholderURL(file, options: any = {}) {
for (let i = 0; i < UPLOAD_PLACEHOLDER.length; i++) { for (let i = 0; i < UPLOAD_PLACEHOLDER.length; i++) {
// console.log(UPLOAD_PLACEHOLDER[i].ext, testOpts(UPLOAD_PLACEHOLDER[i].ext, options)); // console.log(UPLOAD_PLACEHOLDER[i].ext, testOpts(UPLOAD_PLACEHOLDER[i].ext, options));
if (UPLOAD_PLACEHOLDER[i].ext.test(url)) { if (UPLOAD_PLACEHOLDER[i].ext.test(file.extname || file.filename || file.url || file.name)) {
if (testOpts(UPLOAD_PLACEHOLDER[i].ext, options)) { if (testOpts(UPLOAD_PLACEHOLDER[i].ext, options)) {
return UPLOAD_PLACEHOLDER[i].icon || UNKNOWN_FILE_ICON; return UPLOAD_PLACEHOLDER[i].icon || UNKNOWN_FILE_ICON;
} else { } else {
return url; return file.name;
} }
} }
} }
return UNKNOWN_FILE_ICON; return UNKNOWN_FILE_ICON;
}; }
export const getURL = (target: any) => {
return target?.['url'] || target?.['downloadURL'] || target?.['imgURL'] || target?.['name'];
};
export const getThumbURL = (target: any) => {
return target?.['thumbUrl'] || target?.['url'] || target?.['downloadURL'] || target?.['imgURL'] || target?.['name'];
};
export function getResponseMessage({ error, response }: UploadFile<any>) { export function getResponseMessage({ error, response }: UploadFile<any>) {
if (error instanceof Error && 'isAxiosError' in error) { if (error instanceof Error && 'isAxiosError' in error) {
@ -93,24 +118,14 @@ export function getResponseMessage({ error, response }: UploadFile<any>) {
} }
export function normalizeFile(file: UploadFile & Record<string, any>) { export function normalizeFile(file: UploadFile & Record<string, any>) {
const imageUrl = isImage(file) ? URL.createObjectURL(file.originFileObj) : getImageByUrl(file.name);
const response = getResponseMessage(file); const response = getResponseMessage(file);
return { return {
...file, ...file,
title: file.name, title: file.name,
thumbUrl: imageUrl,
imageUrl,
response, response,
}; };
} }
export const normalizeFileList = (fileList: UploadFile[]) => {
if (fileList && fileList.length) {
return fileList.map(normalizeFile);
}
return [];
};
export function useUploadProps<T extends IUploadProps = UploadProps>(props: T) { export function useUploadProps<T extends IUploadProps = UploadProps>(props: T) {
const api = useAPIClient(); const api = useAPIClient();
@ -166,11 +181,6 @@ export const toItem = (file) => {
...file, ...file,
id: file.id || file.uid, id: file.id || file.uid,
title: file.title || file.name, title: file.title || file.name,
imageUrl: isImage(file)
? file.url
: getImageByUrl(file.url, {
exclude: ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'],
}),
}; };
}; };
@ -178,12 +188,6 @@ export const toFileList = (fileList: any) => {
return toArr(fileList).filter(Boolean).map(toItem); return toArr(fileList).filter(Boolean).map(toItem);
}; };
export const toValue = (fileList: any) => {
return toArr(fileList)
.filter((file) => !file.response || file.status === 'done')
.map((file) => file?.response?.data || file);
};
const Rules: Record<string, RuleFunction> = { const Rules: Record<string, RuleFunction> = {
size(file, options: number): null | string { size(file, options: number): null | string {
const size = options ?? FILE_SIZE_LIMIT_DEFAULT; const size = options ?? FILE_SIZE_LIMIT_DEFAULT;