diff --git a/docs/zh-CN/components/pdf-viewer.md b/docs/zh-CN/components/pdf-viewer.md new file mode 100644 index 000000000..d5baa5ad0 --- /dev/null +++ b/docs/zh-CN/components/pdf-viewer.md @@ -0,0 +1,54 @@ +--- +title: PDF Viewer +description: +type: 0 +group: ⚙ 组件 +menuName: PDFViewer 渲染 +icon: +order: 24 +--- + +## 基本用法 + +```schema: scope="body" +{ + "type": "pdf-viewer", + "id": "pdf-viewer", + "src": "/examples/static/simple.pdf" +} +``` + +## 配合文件上传实现预览功能 + +配置和 `input-file` 相同的 `name` 即可 + +```schema: scope="body" +{ + "type": "form", + "title": "", + "wrapWithPanel": false, + "body": [ + { + "type": "input-file", + "name": "file", + "label": "File", + "asBlob": true, + "accept": ".pdf" + }, + { + "type": "pdf-viewer", + "id": "pdf-viewer", + "name": "file" + } + ] +} +``` + +## 属性表 + +| 属性名 | 类型 | 默认值 | 说明 | +| ---------- | ------ | ------ | ---------- | +| src | Api | | 文档地址 | +| width | number | 500 | 宽度 | +| height | number | - | 高度 | +| background | string | #fff | PDF 背景色 | diff --git a/examples/components/Components.tsx b/examples/components/Components.tsx index 4bbc1b029..c6853b0a9 100644 --- a/examples/components/Components.tsx +++ b/examples/components/Components.tsx @@ -987,6 +987,13 @@ export const components = [ import('../../docs/zh-CN/components/office-viewer.md').then(wrapDoc) ) }, + { + label: 'PDFViewer 渲染', + path: '/zh-CN/components/pdf-viewer', + component: React.lazy(() => + import('../../docs/zh-CN/components/pdf-viewer.md').then(wrapDoc) + ) + }, { label: 'Progress 进度条', path: '/zh-CN/components/progress', diff --git a/examples/components/Example.jsx b/examples/components/Example.jsx index fa19ec4c4..3efa54bcc 100644 --- a/examples/components/Example.jsx +++ b/examples/components/Example.jsx @@ -130,6 +130,7 @@ import Tab3Schema from './Tabs/Tab3'; import Loading from './Loading'; import CodeSchema from './Code'; import OfficeViewer from './OfficeViewer'; +import PdfViewer from './PdfViewer'; import InputTableEvent from './EventAction/cmpt-event-action/InputTableEvent'; import WizardPage from './WizardPage'; @@ -912,6 +913,13 @@ export const examples = [ component: makeSchemaRenderer(OfficeViewer) }, + { + label: 'Pdf 预览', + icon: 'fa fa-file-pdf', + path: '/examples/pdf-viewer', + component: makeSchemaRenderer(PdfViewer) + }, + { label: '多 loading', icon: 'fa fa-spinner', diff --git a/examples/components/PdfViewer.jsx b/examples/components/PdfViewer.jsx new file mode 100644 index 000000000..e9d1afbf1 --- /dev/null +++ b/examples/components/PdfViewer.jsx @@ -0,0 +1,23 @@ +export default { + type: 'page', + body: { + type: 'form', + id: 'form', + debug: true, + wrapWithPanel: false, + body: [ + { + type: 'input-file', + name: 'file.test', + label: '选择 PDF 文件预览效果(不会上传到服务器)', + asBlob: true, + accept: '.pdf' + }, + { + type: 'pdf-viewer', + id: 'pdf-viewer', + name: 'file.test' + } + ] + } +}; diff --git a/examples/static/simple.pdf b/examples/static/simple.pdf new file mode 100644 index 000000000..a64ea050b Binary files /dev/null and b/examples/static/simple.pdf differ diff --git a/fis-conf.js b/fis-conf.js index 361abb1be..1080ccf2d 100644 --- a/fis-conf.js +++ b/fis-conf.js @@ -241,7 +241,7 @@ fis.match('/examples/mod.js', { isMod: false }); -fis.match('{markdown-it,moment-timezone}/**', { +fis.match('{markdown-it,moment-timezone,pdfjs-dist}/**', { preprocessor: fis.plugin('js-require-file') }); @@ -503,6 +503,7 @@ if (fis.project.currentMedia() === 'publish-sdk') { '!amis-ui/lib/components/RichText.js', '!amis-ui/lib/components/Tinymce.js', '!amis-ui/lib/components/ColorPicker.js', + '!amis-ui/lib/components/PdfViewer.js', '!react-color/**', '!material-colors/**', '!reactcss/**', @@ -562,6 +563,11 @@ if (fis.project.currentMedia() === 'publish-sdk') { 'tinycolor2/**' ], + 'pdf-viewer.js': [ + 'amis-ui/lib/components/PdfViewer.js', + 'pdfjs-dist/build/pdf.worker.min.js' + ], + 'cropperjs.js': ['cropperjs/**', 'react-cropper/**'], 'barcode.js': ['src/components/BarCode.tsx', 'jsbarcode/**'], @@ -584,6 +590,7 @@ if (fis.project.currentMedia() === 'publish-sdk') { '!mpegts.js/**', '!hls.js/**', '!froala-editor/**', + '!pdfjs-dist/**', '!amis-ui/lib/components/RichText.js', '!zrender/**', diff --git a/packages/amis-ui/package.json b/packages/amis-ui/package.json index 2e3f36747..6c945423e 100644 --- a/packages/amis-ui/package.json +++ b/packages/amis-ui/package.json @@ -65,6 +65,7 @@ "react-intersection-observer": "9.5.2", "react-json-view": "1.21.3", "react-overlays": "5.1.1", + "react-pdf": "^7.7.1", "react-textarea-autosize": "8.3.3", "react-transition-group": "4.4.2", "react-visibility-sensor": "5.1.1", @@ -143,4 +144,4 @@ ] }, "gitHead": "37d23b4a8eb1c663bc38e8dd9040889ea1526ec4" -} \ No newline at end of file +} diff --git a/packages/amis-ui/rollup.config.js b/packages/amis-ui/rollup.config.js index 527df261a..ae15fb0ac 100644 --- a/packages/amis-ui/rollup.config.js +++ b/packages/amis-ui/rollup.config.js @@ -49,7 +49,8 @@ const input = [ './src/components/Markdown.tsx', './src/components/Tinymce.tsx', './src/components/RichText.tsx', - './src/components/CityDB.ts' + './src/components/CityDB.ts', + './src/components/PdfViewer.tsx' ]; /** 获取子包编译后的入口路径,需要使用相对路径 */ diff --git a/packages/amis-ui/scss/components/_pdf_viewer.scss b/packages/amis-ui/scss/components/_pdf_viewer.scss new file mode 100644 index 000000000..e8abd90c7 --- /dev/null +++ b/packages/amis-ui/scss/components/_pdf_viewer.scss @@ -0,0 +1,45 @@ +.#{$ns}PdfViewer { + position: relative; + height: 100%; + min-height: 500px; + display: flex; + justify-content: center; + padding: 50px 0; + &-Content { + width: max-content; + max-width: 100%; + &.is-loaded { + box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 4px 0px; + } + } + + &-Loading { + text-align: center; + } + + &-Tool { + position: absolute; + z-index: 10; + padding: 5px 15px; + bottom: 60px; + background-color: #444444; + border-radius: 10px; + color: #fff; + text-align: center; + user-select: none; + .gap { + margin: 0 4px; + } + .icon { + cursor: pointer; + width: 20px; + margin: 0 10px; + &:hover { + color: var(--colors-brand-5); + } + } + .page-input { + width: 40px; + } + } +} diff --git a/packages/amis-ui/scss/themes/_common.scss b/packages/amis-ui/scss/themes/_common.scss index 1d09879b2..7c87c9275 100644 --- a/packages/amis-ui/scss/themes/_common.scss +++ b/packages/amis-ui/scss/themes/_common.scss @@ -142,3 +142,4 @@ @import '../components/debug'; @import '../components/menu'; @import '../components/overflow-tpl'; +@import '../components/pdf_viewer'; diff --git a/packages/amis-ui/src/components/PdfViewer.tsx b/packages/amis-ui/src/components/PdfViewer.tsx new file mode 100644 index 000000000..2706f2fb2 --- /dev/null +++ b/packages/amis-ui/src/components/PdfViewer.tsx @@ -0,0 +1,143 @@ +/** + * @file PdfViewer.tsx PDF 预览 + * + * @created: 2024/02/26 + */ + +import React from 'react'; +import {themeable, ThemeProps} from 'amis-core'; +import {Document, Page, pdfjs} from 'react-pdf'; + +import {Icon} from './icons'; +import Input from './Input'; +import Spinner from './Spinner'; +// @ts-ignore +import pdfJSWorkerURL from 'pdfjs-dist/build/pdf.worker.min'; +pdfjs.GlobalWorkerOptions.workerSrc = pdfJSWorkerURL; + +export interface PdfViewerProps extends ThemeProps { + file?: ArrayBuffer; + width?: number; + height?: number; + background?: string; +} + +const PdfViewer: React.FC = props => { + const {classnames: cx, className, width = 500} = props; + const [file, setFile] = React.useState(props.file); + const [loaded, setLoaded] = React.useState(false); + const [page, setPage] = React.useState(1); + const [scale, setScale] = React.useState(1); + const [total, setTotal] = React.useState(1); + const inputRef = React.useRef(); + + React.useEffect(() => { + if (props.file instanceof ArrayBuffer && props.file.byteLength > 0) { + setFile(props.file); + } else { + setFile(undefined); + } + }, [props.file]); + + function handleLoadSuccess({numPages}: {numPages: number}) { + setLoaded(true); + setTotal(numPages); + } + + function handleChangePage(idx: number) { + const newPage = page + idx; + if (newPage <= 0 || newPage > total) { + return; + } + setPage(newPage); + } + + function handlePageBlur(event: React.ChangeEvent) { + const newPage = +event.target.value; + if (isNaN(newPage) || newPage <= 0 || newPage > total) { + if (inputRef.current) { + inputRef.current.value = page + ''; + } + return; + } + setPage(newPage); + } + + function handleChangeScale(t: number) { + setScale(scale * t); + } + + if (!file) { + return null; + } + + function renderLoading() { + return ( +
+ +
+ ); + } + + function renderTool() { + return ( +
+ handleChangePage(-1)} + /> + + / + {total} + handleChangePage(1)} + /> + handleChangeScale(1.2)} + /> + handleChangeScale(0.8)} + /> +
+ ); + } + + return ( +
+
+ + No PDF data
} + scale={scale} + renderTextLayer={false} + renderAnnotationLayer={false} + /> + +
+ {loaded ? renderTool() : null} + + ); +}; + +export default themeable(PdfViewer); diff --git a/packages/amis-ui/src/components/TimelineItem.tsx b/packages/amis-ui/src/components/TimelineItem.tsx index 8262e1fe0..ae116843c 100644 --- a/packages/amis-ui/src/components/TimelineItem.tsx +++ b/packages/amis-ui/src/components/TimelineItem.tsx @@ -3,7 +3,7 @@ import {localeable, LocaleProps} from 'amis-core'; import {themeable, ThemeProps} from 'amis-core'; import {Icon} from './icons'; -import type {IconCheckedSchema} from 'amis-ui'; +import type {IconCheckedSchema} from '../index'; export interface TimelineItemProps { /** diff --git a/packages/amis/package.json b/packages/amis/package.json index 0b6269fc1..67501f898 100644 --- a/packages/amis/package.json +++ b/packages/amis/package.json @@ -244,4 +244,4 @@ "react-dom": ">=16.8.6" }, "gitHead": "37d23b4a8eb1c663bc38e8dd9040889ea1526ec4" -} \ No newline at end of file +} diff --git a/packages/amis/src/Schema.ts b/packages/amis/src/Schema.ts index 5f813bc7d..89fabe370 100644 --- a/packages/amis/src/Schema.ts +++ b/packages/amis/src/Schema.ts @@ -253,6 +253,7 @@ export type SchemaType = | 'input-formula' | 'diff-editor' | 'office-viewer' + | 'pdf-viewer' // editor 系列 | 'editor' diff --git a/packages/amis/src/index.tsx b/packages/amis/src/index.tsx index 4b8d4900d..906ac577e 100644 --- a/packages/amis/src/index.tsx +++ b/packages/amis/src/index.tsx @@ -153,6 +153,7 @@ import './renderers/Password'; import './renderers/DateRange'; import './renderers/MultilineText'; import './renderers/OfficeViewer'; +import './renderers/PdfViewer'; import './renderers/AMIS'; import './compat'; diff --git a/packages/amis/src/renderers/PdfViewer.tsx b/packages/amis/src/renderers/PdfViewer.tsx new file mode 100644 index 000000000..2390e3e4a --- /dev/null +++ b/packages/amis/src/renderers/PdfViewer.tsx @@ -0,0 +1,189 @@ +/** + * @file PdfViewer.tsx PDF 预览 + * + * @created: 2024/02/26 + */ + +import React from 'react'; +import { + autobind, + getVariable, + isApiOutdated, + IScopedContext, + Renderer, + RendererProps, + resolveVariableAndFilter, + ScopedContext +} from 'amis-core'; +import {BaseSchema} from '../Schema'; + +export const PdfView = React.lazy( + () => import('amis-ui/lib/components/PdfViewer') +); + +export interface PdfViewerSchema extends BaseSchema { + type: 'pdf-viewer'; + /** + * 文件地址 + */ + src?: string; + /** + * 文件取值,一般配个表单使用 + */ + name?: string; + width?: number; + height?: number; + background?: string; +} + +export interface PdfViewerProps extends RendererProps {} + +interface PdfViewerState { + loading: boolean; +} + +export default class PdfViewer extends React.Component< + PdfViewerProps, + PdfViewerState +> { + file?: ArrayBuffer; + reader?: FileReader; + fetchCancel?: Function; + constructor(props: PdfViewerProps) { + super(props); + this.state = { + loading: false + }; + } + + componentDidMount() { + this.renderPdf(); + } + + componentDidUpdate(prevProps: PdfViewerProps) { + const props = this.props; + + if (isApiOutdated(prevProps.src, props.src, prevProps.data, props.data)) { + this.abortLoad(); + this.fetchPdf(); + } + + if (getVariable(props.data, props.name)) { + if ( + getVariable(prevProps.data, prevProps.name) !== + getVariable(props.data, props.name) + ) { + this.abortLoad(); + this.renderPdf(); + } + } + } + + @autobind + abortLoad() { + if (this.fetchCancel) { + this.fetchCancel('load canceled'); + this.fetchCancel = undefined; + } + if (this.reader) { + this.reader.abort(); + this.reader = undefined; + } + } + + @autobind + async renderPdf() { + const {src, name, data} = this.props; + // src 优先级高于 name + if (src) { + if (!this.file) { + await this.fetchPdf(); + } + } else if (getVariable(data, name)) { + await this.renderFormFile(); + } + } + + @autobind + async fetchPdf() { + const {env, src, data, translate: __} = this.props; + const finalSrc = src + ? resolveVariableAndFilter(src, data, '| raw') + : undefined; + + if (!finalSrc) { + console.warn('file src is empty'); + return; + } + + this.setState({ + loading: true + }); + + try { + const res = await env.fetcher(finalSrc, data, { + responseType: 'arraybuffer', + cancelExecutor: (executor: Function) => (this.fetchCancel = executor) + }); + this.file = res.data; + this.forceUpdate(); + } catch (error) { + console.error(error); + } finally { + this.setState({ + loading: false + }); + } + } + + @autobind + async renderFormFile() { + const {name, data} = this.props; + const file = getVariable(data, name); + if (file instanceof File) { + const reader = new FileReader(); + reader.onload = _e => { + const data = reader.result as ArrayBuffer; + this.file = data; + this.forceUpdate(); + }; + reader.readAsArrayBuffer(file); + this.reader = reader; + } + } + + render() { + const {className, classnames: cx, width, height, background} = this.props; + + return ( + + ); + } +} + +@Renderer({ + type: 'pdf-viewer' +}) +export class PdfViewerRenderer extends PdfViewer { + static contextType = ScopedContext; + + constructor(props: PdfViewerProps, context: IScopedContext) { + super(props); + + const scoped = context; + scoped.registerComponent(this); + } + + componentWillUnmount() { + super.componentWillUnmount?.(); + const scoped = this.context as IScopedContext; + scoped.unRegisterComponent(this); + } +}