From da1646570dda243732dca556b2345aa9024f8216 Mon Sep 17 00:00:00 2001 From: Kev1nzh <66104521+kev1nzh37@users.noreply.github.com> Date: Tue, 31 Aug 2021 01:12:38 +0800 Subject: [PATCH] feat(upload): add list type (#785) * feat(upload): add `list-type`, `show-preview-button`, `on-preview` and `preview-file` props, thumbail style and some tests (#393) * docs(upload): add thumbail api (#393) * docs(upload): add CHANGELOG (#393) * docs(upload): add fix CHANGELOG (#393) * fix(upload): fix husky file * Update .husky/pre-commit * Update src/upload/demos/enUS/picture-style.demo.md Co-authored-by: 07akioni <07akioni2@gmail.com> * Update src/upload/demos/zhCN/index.demo-entry.md Co-authored-by: 07akioni <07akioni2@gmail.com> * Update src/upload/src/Upload.tsx Co-authored-by: 07akioni <07akioni2@gmail.com> * Update src/upload/src/styles/index.cssr.ts Co-authored-by: 07akioni <07akioni2@gmail.com> * feat(upload): update variable name and fix style (#393) * docs(upload): add preview-file prop api and fix CHANGELOG (#393) * feat(upload): fix component attribute (#393) * feat(upload): change create thumnail url and fix bug * feat(upload): change code location (#393) * docs(upload): change demo and CHANGELOG (#393) * feat(upload): fix husky(#393) * Update src/upload/src/UploadFile.tsx Co-authored-by: 07akioni <07akioni2@gmail.com> * Update src/upload/demos/zhCN/picture-card-style.demo.md Co-authored-by: 07akioni <07akioni2@gmail.com> * Update src/upload/demos/zhCN/picture-style.demo.md Co-authored-by: 07akioni <07akioni2@gmail.com> * feat(upload): fix preview animation error, preview ref and picture style (#393) * feat(upload): add image group to the file list (#393) Co-authored-by: kev1nzh Co-authored-by: 07akioni <07akioni2@gmail.com> --- CHANGELOG.en-US.md | 1 + CHANGELOG.zh-CN.md | 1 + src/_internal/icons/File.tsx | 21 ++ src/_internal/icons/Photo.tsx | 23 ++ src/_internal/icons/index.ts | 2 + src/image/src/Image.tsx | 3 + src/upload/demos/enUS/index.demo-entry.md | 6 + .../demos/enUS/picture-card-style.demo.md | 119 ++++++++ src/upload/demos/enUS/picture-style.demo.md | 61 ++++ src/upload/demos/zhCN/index.demo-entry.md | 10 +- .../demos/zhCN/picture-card-style.demo.md | 119 ++++++++ src/upload/demos/zhCN/picture-style.demo.md | 59 ++++ src/upload/src/Upload.tsx | 165 +++++++++-- src/upload/src/UploadFile.tsx | 227 +++++++++++++-- src/upload/src/interface.ts | 14 +- src/upload/src/styles/index.cssr.ts | 268 ++++++++++++++++-- src/upload/styles/light.ts | 3 +- src/upload/tests/Upload.spec.ts | 160 +++++++++++ 18 files changed, 1195 insertions(+), 67 deletions(-) create mode 100644 src/_internal/icons/File.tsx create mode 100644 src/_internal/icons/Photo.tsx create mode 100644 src/upload/demos/enUS/picture-card-style.demo.md create mode 100644 src/upload/demos/enUS/picture-style.demo.md create mode 100644 src/upload/demos/zhCN/picture-card-style.demo.md create mode 100644 src/upload/demos/zhCN/picture-style.demo.md diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index c910c76a1..ea636588c 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -106,6 +106,7 @@ - `n-button` add `text-color` prop. - `n-form` export `FormValidationError` type. - `n-popconfirm` support not show action components, closes [#770](https://github.com/TuSimple/naive-ui/issues/770). +- `n-upload` add `list-type`, `show-preview-button`, `on-preview` and `create-thumbnail-url` prop. ### Fixes diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 63d42c41c..56d6a3a7a 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -106,6 +106,7 @@ - `n-button` 新增 `text-color` 属性 - `n-form` 导出 `FormValidationError` 类型 - `n-popconfirm` 支持不显示操作组件,关闭 [#770](https://github.com/TuSimple/naive-ui/issues/770) +- `n-upload` 增加 `list-type`、 `show-preview-button`、 `on-preview` 和 `create-thumbnail-url` 属性 ### Fixes diff --git a/src/_internal/icons/File.tsx b/src/_internal/icons/File.tsx new file mode 100644 index 000000000..ceb400e80 --- /dev/null +++ b/src/_internal/icons/File.tsx @@ -0,0 +1,21 @@ +import { h, defineComponent } from 'vue' + +export default defineComponent({ + name: 'File', + render () { + return ( + + + + + + + ) + } +}) diff --git a/src/_internal/icons/Photo.tsx b/src/_internal/icons/Photo.tsx new file mode 100644 index 000000000..cd64df213 --- /dev/null +++ b/src/_internal/icons/Photo.tsx @@ -0,0 +1,23 @@ +import { h, defineComponent } from 'vue' + +export default defineComponent({ + name: 'Photo', + render () { + return ( + + + + + + + + + ) + } +}) diff --git a/src/_internal/icons/index.ts b/src/_internal/icons/index.ts index 466071706..ae909bf0e 100644 --- a/src/_internal/icons/index.ts +++ b/src/_internal/icons/index.ts @@ -36,3 +36,5 @@ export { default as RotateClockwiseIcon } from './RotateClockwise' export { default as RotateCounterclockwiseIcon } from './RotateCounterclockwise' export { default as ZoomInIcon } from './ZoomIn' export { default as ZoomOutIcon } from './ZoomOut' +export { default as FileIcon } from './File' +export { default as PhotoIcon } from './Photo' diff --git a/src/image/src/Image.tsx b/src/image/src/Image.tsx index e8df3c3a1..12e760c6b 100644 --- a/src/image/src/Image.tsx +++ b/src/image/src/Image.tsx @@ -24,6 +24,9 @@ interface imgProps { usemap?: string width?: number } +export interface ImageInst { + handleClick: () => void +} const imageProps = { alt: String, diff --git a/src/upload/demos/enUS/index.demo-entry.md b/src/upload/demos/enUS/index.demo-entry.md index 68f659b5c..ca29c95f5 100644 --- a/src/upload/demos/enUS/index.demo-entry.md +++ b/src/upload/demos/enUS/index.demo-entry.md @@ -12,6 +12,8 @@ controlled on-finish default-files before-upload +picture-card-style +picture-style ``` ## Props @@ -29,6 +31,7 @@ before-upload | file-list-style | `Object` | `undefined` | The style of file list area | | file-list | `Array` | `undefined` | The file list of component. If set, the component will work in controlled manner. | | headers | `Object \| ({ file: UploadFile }) => Object` | `undefined` | The additional HTTP Headers of request. | +| list-type | `string` | `'text'` | Built-in styles for file lists, `text`, `picture` and `picture-card`. | | method | `string` | `'POST'` | The method of HTTP request. | | multiple | `boolean` | `false` | If multiple files selection supported. | | name | `string` | `'file'` | The field name of file in form data. | @@ -36,12 +39,15 @@ before-upload | show-remove-button | `boolean` | `true` | Whether to show remove button (at file finished status). Click on remove button will fire `on-remove` callback. | | show-retry-button | `boolean` | `true` | Whether to show retry button (at file error status). | | show-file-list | `boolean` | `true` | Whether to show file list. | +| show-preview-button | `boolean` | `true` | Whether to show the preview button (shown when `list-type` is `picture-card`). | | with-credentials | `boolean` | `false` | If cookie attached. | | on-change | `(options: { file: UploadFile, fileList: Array, event?: Event }) => void` | `() => {}` | The callback of status change of the component. Any file status change would fire the callback. | | on-update:file-list | `(fileList: UploadFile[]) => void` | `undefined` | Callback function triggered on fileList changes. | | on-finish | `(options: { file: UploadFile, event: Event }) => UploadFile \| void` | `({ file }) => file` | The callback of file upload finish. You can modify the UploadFile or retun a new UploadFile. | | on-remove | `(options: { file: UploadFile, fileList: Array }) => boolean \| Promise \| any` | `() => true` | The callback of file removal. Return false, promise resolve false or promise reject will cancel this removal. | | on-before-upload | `(options: { file: UploadFile, fileList: Array }) => (Promise \| boolean \| void)` | `true` | Callback before file is uploaded, return false or a Promise that resolve false or reject will cancel this upload. | +| on-preview | `(file: FileInfo) => void` | `undefined` | Callback functions for clicking on file links or preview buttons. | +| create-thumbnail-url | `(file: File) => Promise` | `undefined` | Customize file thumbnails. | ### UploadFile Type diff --git a/src/upload/demos/enUS/picture-card-style.demo.md b/src/upload/demos/enUS/picture-card-style.demo.md new file mode 100644 index 000000000..8de75c707 --- /dev/null +++ b/src/upload/demos/enUS/picture-card-style.demo.md @@ -0,0 +1,119 @@ +# Pictures Wall + +`list-type = 'picture-card'` + +The preview in the photo wall will call the internal component by default, you can also use `on-preview` to customize the method of showing uploaded files. + +```html + +
+ + + + Upload +
+
+ + +
+ + + + Upload +
+
+ + + + +``` + +```js +import { IosAdd } from '@vicons/ionicons4' +export default { + components: { + IosAdd + }, + methods: { + handlePreview (file) { + const { url, thumbUrl } = file + this.previewImageUrl = url || thumbUrl + this.showModal = true + } + }, + data () { + return { + style: { + display: 'flex', + 'justify-content': 'center', + 'align-items': 'center', + height: '100%', + 'flex-direction': 'column' + }, + imgStyle: { + width: '100%' + }, + modalStyle: { + width: '600px' + }, + showModal: false, + previewImageUrl: '', + fileList: [ + { + id: 'a', + name: 'I am a regular file with errors.png', + status: 'error' + }, + { + id: 'b', + name: 'I am a regular file.doc', + status: 'finished', + type: 'text/plain' + }, + { + id: 'c', + name: 'I am a regular file with url.png', + status: 'finished', + url: 'https://gw.alipayobjects.com/zos/antfincdn/aPkFc8Sj7n/method-draw-image.svg' + }, + { + id: 'd', + name: 'I am uploading a normal file.doc', + status: 'uploading', + percentage: 99 + } + ], + previewFileList: [ + { + id: 'react', + name: 'I am react.png', + status: 'finished', + url: 'https://gw.alipayobjects.com/zos/antfincdn/aPkFc8Sj7n/method-draw-image.svg' + }, + { + id: 'vue', + name: 'I am vue.png', + status: 'finished', + url: 'https://cn.vuejs.org/images/logo.svg' + } + ] + } + } +} +``` diff --git a/src/upload/demos/enUS/picture-style.demo.md b/src/upload/demos/enUS/picture-style.demo.md new file mode 100644 index 000000000..2b1f86e47 --- /dev/null +++ b/src/upload/demos/enUS/picture-style.demo.md @@ -0,0 +1,61 @@ +# Thumbnail File List + +`list-type = 'picture'` + +You can use `preview-file` to customize the thumbnails of the file. + +```html + + Upload + +``` + +```js +import { defineComponent, ref } from 'vue' +import { useMessage } from 'naive-ui' + +export default defineComponent({ + setup () { + const message = useMessage() + const fileListRef = ref([ + { + id: 'a', + name: 'I am a regular file with errors.png', + status: 'error' + }, + { + id: 'b', + name: 'I am a regular file.doc', + status: 'finished', + type: 'text/plain' + }, + { + id: 'c', + name: 'I am a regular file with url.png', + status: 'finished', + url: 'https://gw.alipayobjects.com/zos/antfincdn/aPkFc8Sj7n/method-draw-image.svg' + }, + { + id: 'd', + name: 'I am uploading a normal file.doc', + status: 'uploading', + percentage: 99 + } + ]) + return { + fileList: fileListRef, + createThumbnailUrl (file) { + message.info( + 'previewFile changes the thumbnail image of the uploaded file so that it looks all Vue.' + ) + return 'https://cn.vuejs.org/images/logo.svg' + } + } + } +}) +``` diff --git a/src/upload/demos/zhCN/index.demo-entry.md b/src/upload/demos/zhCN/index.demo-entry.md index 2563b2825..fa90cfa8e 100644 --- a/src/upload/demos/zhCN/index.demo-entry.md +++ b/src/upload/demos/zhCN/index.demo-entry.md @@ -12,6 +12,8 @@ controlled on-finish default-files before-upload +picture-card-style +picture-style ``` ## Props @@ -29,6 +31,7 @@ before-upload | file-list-style | `Object` | `undefined` | 文件列表区域的样式 | | file-list | `Array` | `undefined` | 文件列表,如果传入组件会处于受控状态 | | headers | `Object \| ({ file: UploadFile }) => Object` | `undefined` | HTTP 请求需要附加的 Headers | +| list-type | `string` | `'text'` | 文件列表的内建样式,`text`、`picture` 和 `picture-card` | | method | `string` | `'POST'` | HTTP 请求的方法 | | multiple | `boolean` | `false` | 是否支持多个文件 | | name | `string` | `'file'` | 文件在提交表单中的字段名 | @@ -36,11 +39,14 @@ before-upload | show-remove-button | `boolean` | `true` | 是否显示删除按钮(在 finished 的时候展示),点击删除按钮会触发 `on-remove` 回调 | | show-retry-button | `boolean` | `true` | 是否显示重新上传按钮(在 error 时展示) | | show-file-list | `boolean` | `true` | 是否显示文件列表 | +| show-preview-button | `boolean` | `true` | 是否显示预览按钮(在 `list-type` 为 `picture-card` 时展示) | | with-credentials | `boolean` | `false` | 是否携带 Cookie | | on-change | `(options: { file: UploadFile, fileList: Array, event?: Event }) => void` | `() => {}` | 组件状态变化的回调,组件的任何文件状态变化都会触发回调 | | on-finish | `(options: { file: UploadFile, event: Event }) => UploadFile \| void` | `({ file }) => file` | 文件上传结束的回调,可以修改传入的 UploadFile 或者返回一个新的 UploadFile | | on-update:file-list | `(fileList: UploadFile[]) => void` | `undefined` | 当 file-list 改变时触发的回调函数 | | on-before-upload | `(options: { file: UploadFile, fileList: UploadFile[] }) => (Promise \| boolean \| void)` | `undefined` | 文件上传之前的回调,返回 `false`、`Promise resolve false`、`Promise rejected` 时会取消本次上传 | +| on-preview | `(file: FileInfo) => void` | `undefined` | 点击文件链接或预览按钮的回调函数 | +| create-thumbnail-url | `(file: File) => Promise` | `undefined` | 自定义文件缩略图 | ### UploadFile Type @@ -71,6 +77,6 @@ before-upload ### Upload Dragger Slots -| 名称 | 参数 | 说明 | -| ------- | ---- | ------------------------------------ | +| 名称 | 参数 | 说明 | +| ------- | ---- | --------------------------------------------- | | default | `()` | 上传拖动器的内容,使用可参考[拖拽上传](#drag) | diff --git a/src/upload/demos/zhCN/picture-card-style.demo.md b/src/upload/demos/zhCN/picture-card-style.demo.md new file mode 100644 index 000000000..76ae36d4e --- /dev/null +++ b/src/upload/demos/zhCN/picture-card-style.demo.md @@ -0,0 +1,119 @@ +# 照片墙 + +`list-type = 'picture-card'` + +照片墙中的预览会默认调用内部组件,你也可以使用 `on-preview` 自定义展示上传文件的方法 + +```html + +
+ + + + Upload +
+
+ + +
+ + + + Upload +
+
+ + + + +``` + +```js +import { IosAdd } from '@vicons/ionicons4' +export default { + components: { + IosAdd + }, + methods: { + handlePreview (file) { + const { url, thumbUrl } = file + this.previewImageUrl = url || thumbUrl + this.showModal = true + } + }, + data () { + return { + style: { + display: 'flex', + 'justify-content': 'center', + 'align-items': 'center', + height: '100%', + 'flex-direction': 'column' + }, + imgStyle: { + width: '100%' + }, + modalStyle: { + width: '600px' + }, + showModal: false, + previewImageUrl: '', + fileList: [ + { + id: 'a', + name: '我是上传出错的普通文件.png', + status: 'error' + }, + { + id: 'b', + name: '我是普通文本.doc', + status: 'finished', + type: 'text/plain' + }, + { + id: 'c', + name: '我是自带url的图片.png', + status: 'finished', + url: 'https://gw.alipayobjects.com/zos/antfincdn/aPkFc8Sj7n/method-draw-image.svg' + }, + { + id: 'd', + name: '我是上传进度99%的文本.doc', + status: 'uploading', + percentage: 99 + } + ], + previewFileList: [ + { + id: 'react', + name: '我是react.png', + status: 'finished', + url: 'https://gw.alipayobjects.com/zos/antfincdn/aPkFc8Sj7n/method-draw-image.svg' + }, + { + id: 'vue', + name: '我是vue.png', + status: 'finished', + url: 'https://cn.vuejs.org/images/logo.svg' + } + ] + } + } +} +``` diff --git a/src/upload/demos/zhCN/picture-style.demo.md b/src/upload/demos/zhCN/picture-style.demo.md new file mode 100644 index 000000000..63f136fef --- /dev/null +++ b/src/upload/demos/zhCN/picture-style.demo.md @@ -0,0 +1,59 @@ +# 缩略图文件列表 + +`list-type = 'picture'` + +你可以使用 `preview-file`自定义文件的缩略图 + +```html + + 上传文件 + +``` + +```js +import { defineComponent, ref } from 'vue' +import { useMessage } from 'naive-ui' + +export default defineComponent({ + setup () { + const message = useMessage() + const fileListRef = ref([ + { + id: 'a', + name: '我是上传出错的普通文件.png', + status: 'error' + }, + { + id: 'b', + name: '我是普通文本.doc', + status: 'finished', + type: 'text/plain' + }, + { + id: 'c', + name: '我是自带url的图片.png', + status: 'finished', + url: 'https://gw.alipayobjects.com/zos/antfincdn/aPkFc8Sj7n/method-draw-image.svg' + }, + { + id: 'd', + name: '我是上传进度99%的文本.doc', + status: 'uploading', + percentage: 99 + } + ]) + return { + fileList: fileListRef, + createThumbnailUrl (file) { + message.info('previewFile改变了上传文件的缩略图,让它看起来都是Vue。') + return 'https://cn.vuejs.org/images/logo.svg' + } + } + } +}) +``` diff --git a/src/upload/src/Upload.tsx b/src/upload/src/Upload.tsx index 76edb6006..db4bdb890 100644 --- a/src/upload/src/Upload.tsx +++ b/src/upload/src/Upload.tsx @@ -34,10 +34,14 @@ import { OnChange, uploadInjectionKey, OnUpdateFileList, - OnBeforeUpload + OnBeforeUpload, + listType, + OnPreview, + CreateThumbnailUrl } from './interface' import { useMergedState } from 'vooks' import { uploadDraggerKey } from './UploadDragger' +import { NImageGroup } from '../../image' /** * fils status ['pending', 'uploading', 'finished', 'removed', 'error'] @@ -240,9 +244,50 @@ const uploadProps = { showRetryButton: { type: Boolean, default: true - } + }, + showPreivewButton: { + type: Boolean, + default: true + }, + listType: { + type: String as PropType, + default: 'text' + }, + onPreview: Function as PropType, + createThumbnailUrl: Function as PropType } as const +const isImageFileType = (type: string): boolean => type.includes('image/') + +const extname = (url: string = ''): string => { + const temp = url.split('/') + const filename = temp[temp.length - 1] + const filenameWithoutSuffix = filename.split(/#|\?/)[0] + return (/\.[^./\\]*$/.exec(filenameWithoutSuffix) || [''])[0] +} + +const isImageUrl = (file: FileInfo): boolean => { + if (file.type && !file.thumbnailUrl) { + return isImageFileType(file.type) + } + const url: string = file.thumbnailUrl || file.url || '' + const extension = extname(url) + if ( + /^data:image\//.test(url) || + /(webp|svg|png|gif|jpg|jpeg|jfif|bmp|dpg|ico)$/i.test(extension) + ) { + return true + } + if (/^data:/.test(url)) { + return false + } + if (extension) { + return false + } + + return true +} + export type UploadProps = ExtractPublicPropTypes export default defineComponent({ @@ -316,7 +361,6 @@ export default defineComponent({ function handleFileAddition (files: FileList | null, e?: Event): void { if (!files || files.length === 0) return const { onBeforeUpload } = props - const filesAsArray = props.multiple ? Array.from(files) : [files[0]] void Promise.all( filesAsArray.map(async (file) => { @@ -326,7 +370,8 @@ export default defineComponent({ status: 'pending', percentage: 0, file: file, - url: null + url: null, + type: file.type } if ( !onBeforeUpload || @@ -417,6 +462,41 @@ export default defineComponent({ warn('upload', 'File has no corresponding id in current file list.') } } + async function getFileThumbnail (file: FileInfo): Promise { + const { createThumbnailUrl } = props + + return createThumbnailUrl + ? await createThumbnailUrl(file.file as File) + : await previewImage(file.file as File) + } + + async function previewImage (file: File): Promise { + return await new Promise((resolve) => { + if (!file.type || !isImageFileType(file.type)) { + resolve('') + return + } + + const img = new Image() + img.onload = () => { + const { width, height } = img + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + canvas.width = width + canvas.height = height + canvas.style.cssText = `position: fixed; left: 0; top: 0; width: ${width}px; height: ${height}px; z-index: 9999; display: none;` + document.body.appendChild(canvas) + + ctx?.drawImage(img, 0, 0, width, height) + const dataURL = canvas.toDataURL() + document.body.removeChild(canvas) + + resolve(dataURL) + } + img.src = window.URL.createObjectURL(file) + }) + } + provide(uploadInjectionKey, { mergedClsPrefixRef, mergedThemeRef: themeRef, @@ -430,7 +510,11 @@ export default defineComponent({ mergedFileListRef: mergedFileListRef, XhrMap, submit, - doChange + doChange, + isImageUrl, + showPreivewButtonRef: toRef(props, 'showPreivewButton'), + onPreviewRef: toRef(props, 'onPreview'), + getFileThumbnail }) return { mergedClsPrefix: mergedClsPrefixRef, @@ -464,7 +548,8 @@ export default defineComponent({ itemDisabledOpacity, lineHeight, borderRadius, - fontSize + fontSize, + itemIconErrorColor } } = themeRef.value return { @@ -481,7 +566,8 @@ export default defineComponent({ '--item-text-color': itemTextColor, '--item-text-color-error': itemTextColorError, '--item-text-color-success': itemTextColorSuccess, - '--line-height': lineHeight + '--line-height': lineHeight, + '--item-icon-error-color': itemIconErrorColor } }) } @@ -495,6 +581,38 @@ export default defineComponent({ draggerInsideRef.value = true } } + const uploadTrigger = ( +
+ {this.$slots} +
+ ) + const uploadFileList = ( + + {{ + default: () => + this.mergedFileList.map((file) => ( + + )) + }} + + ) + return (
-
- {this.$slots} -
+ {this.listType !== 'picture-card' && uploadTrigger} {this.showFileList && (
- - {{ - default: () => - this.mergedFileList.map((file) => ( - - )) - }} - + {this.listType === 'picture-card' ? ( + + {{ + default: () => uploadFileList + }} + + ) : ( + uploadFileList + )} + {this.listType === 'picture-card' && uploadTrigger}
)}
diff --git a/src/upload/src/UploadFile.tsx b/src/upload/src/UploadFile.tsx index 3d3ba4cc3..a8808ac47 100644 --- a/src/upload/src/UploadFile.tsx +++ b/src/upload/src/UploadFile.tsx @@ -1,16 +1,30 @@ -import { h, defineComponent, PropType, computed, inject } from 'vue' +import { + h, + defineComponent, + PropType, + computed, + inject, + ref, + watchEffect, + VNode +} from 'vue' import { CancelIcon, TrashIcon, AttachIcon, RetryIcon, - DownloadIcon + DownloadIcon, + FileIcon, + PhotoIcon, + EyeIcon } from '../../_internal/icons' import { NButton } from '../../button' -import { NIconSwitchTransition, NBaseIcon } from '../../_internal' +import { NIconSwitchTransition, NBaseIcon, NBaseLoading } from '../../_internal' import { warn } from '../../_utils' import NUploadProgress from './UploadProgress' -import { FileInfo, uploadInjectionKey } from './interface' +import { FileInfo, listType, uploadInjectionKey } from './interface' +import { NImage } from '../../image' +import { ImageInst } from '../../image/src/Image' export default defineComponent({ name: 'UploadFile', @@ -22,11 +36,19 @@ export default defineComponent({ file: { type: Object as PropType, required: true + }, + listType: { + type: String as PropType, + default: 'text' } }, setup (props) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const NUpload = inject(uploadInjectionKey)! + + const imageRef = ref(null) + const thumbnailUrl = ref('') + const progressStatusRef = computed(() => { const { file } = props if (file.status === 'finished') return 'success' @@ -62,6 +84,18 @@ export default defineComponent({ const { file } = props return ['error'].includes(file.status) }) + const showPreivewButtonRef = computed(() => { + if (!NUpload.showPreivewButtonRef.value) return false + const { + file: { status, url }, + listType + } = props + return ( + ['finished'].includes(status) && + (url || thumbnailUrl.value) && + listType === 'picture-card' + ) + }) function handleRetryClick (): void { NUpload.submit(props.file.id) } @@ -121,6 +155,46 @@ export default defineComponent({ XHR?.abort() handleRemove(Object.assign({}, file)) } + function isImageUrl (file: FileInfo): boolean { + return NUpload.isImageUrl(file) + } + function handlePreviewClick (e: MouseEvent): void { + const { + onPreviewRef: { value: onPreview } + } = NUpload + + if (onPreview) { + e.preventDefault() + onPreview(props.file) + } else if (props.listType === 'picture-card') { + const { value } = imageRef + if (!value) return + value.handleClick() + } + } + + const getFileThumbnail = async (): Promise => { + if (props.listType !== 'picture' && props.listType !== 'picture-card') { + return + } + + if ( + typeof document === 'undefined' || + typeof window === 'undefined' || + !window.FileReader || + !window.File || + !(props.file.file instanceof File) + ) { + return + } + + thumbnailUrl.value = await NUpload.getFileThumbnail(props.file) + } + + watchEffect(() => { + void getFileThumbnail() + }) + return { mergedTheme: NUpload.mergedThemeRef, progressStatus: progressStatusRef, @@ -133,30 +207,149 @@ export default defineComponent({ showRetryButton: showRetryButtonRef, handleRemoveOrCancelClick, handleDownloadClick, - handleRetryClick + handleRetryClick, + isImageUrl, + showPreivewButton: showPreivewButtonRef, + handlePreviewClick, + thumbnailUrl, + imageRef } }, render () { const { clsPrefix, mergedTheme } = this + + const thumbnailNameClass = [`${clsPrefix}-upload-file-info-thumbnail__name`] + const fileIcon = (icon: VNode): VNode => ( + + {{ default: () => icon }} + + ) + // if there is text list type, show file icon + let icon = ( + + {{ default: () => }} + + ) + + if (this.listType === 'picture' || this.listType === 'picture-card') { + if (this.file.status === 'uploading') { + icon = + this.listType === 'picture-card' ? ( + Upload.. + ) : ( + + ) + } else { + icon = !this.isImageUrl(this.file) ? ( + fileIcon() + ) : (this.file.url || this.thumbnailUrl) && + this.file.status !== 'error' ? ( + this.handlePreviewClick(e)} + > + {this.listType === 'picture-card' ? ( + + ) : ( + {this.file.name} + )} + + ) : ( + fileIcon() + ) + } + } + return ( -
-
- - {{ default: () => }} - - {this.file.name} + -
+
+ {this.showPreivewButton ? ( + + {{ + icon: () => ( + + {{ default: () => }} + + ) + }} + + ) : null} {(this.showRemoveButton || this.showCancelButton) && !this.disabled && ( - +
) } }) diff --git a/src/upload/src/interface.ts b/src/upload/src/interface.ts index 80e6b039a..6d056678a 100644 --- a/src/upload/src/interface.ts +++ b/src/upload/src/interface.ts @@ -8,7 +8,9 @@ export interface FileInfo { url: string | null percentage: number status: 'pending' | 'uploading' | 'finished' | 'removed' | 'error' - file: File | null + file: File | null | Blob + thumbnailUrl?: string + type?: string } export type FuncOrRecordOrUndef = @@ -65,6 +67,10 @@ export interface UploadInjection { XhrMap: Map submit: (fileId?: string) => void doChange: DoChange + isImageUrl: (file: FileInfo) => boolean + showPreivewButtonRef: Ref + onPreviewRef: Ref + getFileThumbnail: (file: FileInfo) => Promise } export const uploadInjectionKey: InjectionKey = @@ -85,3 +91,9 @@ export type OnBeforeUpload = (data: { file: FileInfo fileList: FileInfo[] }) => Promise + +export type listType = 'text' | 'picture' | 'picture-card' + +export type OnPreview = (file: FileInfo) => void + +export type CreateThumbnailUrl = (file: File) => Promise diff --git a/src/upload/src/styles/index.cssr.ts b/src/upload/src/styles/index.cssr.ts index 4457d67ec..60a8ed847 100644 --- a/src/upload/src/styles/index.cssr.ts +++ b/src/upload/src/styles/index.cssr.ts @@ -11,7 +11,26 @@ export default cB('upload', [ `), cE('trigger', ` display: inline-block; - `), + `, [ + cM('picture-card', ` + position: relative; + display: inline-block; + width: 104px; + height: 104px; + margin: 0 8px 8px 0; + vertical-align: top; + padding: 8px; + cursor: pointer; + box-sizing: border-box; + transition: border-color .3s var(--bezier), background-color .3s var(--bezier); + background-color: var(--dragger-color); + border: var(--dragger-border); + `, [ + c('&:hover', ` + border: var(--dragger-border-hover); + `) + ]) + ]), cM('dragger-inside', [ cE('trigger', ` display: block; @@ -65,25 +84,208 @@ export default cB('upload', [ `) ]) ]), + cM('picture-type', ` + height: 66px; + padding: 8px; + border: 1px solid; + border-color: var(--border-color); + border-radius: var(--border-radius); + margin-top: 8px; + text-decoration: underline; + text-decoration-color: #0000; + `, [ + cB('upload-file-info', ` + padding-top: 0px; + padding-bottom: 0px; + width: 100%; + height: 100%; + display: flex; + justify-content: space-between; + align-items: center; + `, [ + cB('upload-file-info-thumbnail', ` + height: 100%; + display: flex; + justify-content: flex-start; + align-items: center; + min-width:0; + flex:1; + `, + [ + cE('name', ` + flex: auto; + margin-left: 8px; + padding: 0 8px; + line-height: 48px; + height: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + text-decoration: underline; + text-decoration-color: #0000; + font-size: var(--font-size); + transition: + color .3s var(--bezier), + text-decoration-color .3s var(--bezier); + color: var(--item-text-color); + `), + cE('spin', ` + color: var(--loading-color); + width: 48px; + height: 48px; + display: flex; + justify-content: center; + align-items: center; + font-size: 26px; + `), + cE('image', ` + opacity: .8; + width: 48px; + height: 48px; + display: flex; + align-content: center; + justify-content: center; + align-items: center; + flex-shrink: 0; + `, [ + c('img', ` + width: 100%; + display: block; + overflow: hidden; + `), + c('i', ` + font-size: 34px; + `) + ]) + ]) + ]) + ]), + cM('picture-card-type', ` + position: relative; + display: inline-block; + width: 104px; + height: 104px; + margin: 0 8px 8px 0; + vertical-align: top; + padding: 8px; + border: 1px solid; + border-color: var(--border-color); + border-radius: var(--border-radius); + `, [ + cB('upload-file-info', ` + padding: 0; + width: 100%; + height: 100%; + `, [ + cB('upload-file-info-thumbnail', ` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + `, [ + cE('image', ` + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + `, [ + c('img', ` + width: 100%; + `), + c('i', ` + font-size: 34px; + `) + ]), + cE('name', ` + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 100%; + position: relative; + bottom: 10px; + color: var(--item-text-color); + font-size: var(--font-size); + text-align: center; + height: 24px; + `), + cE('hide', 'display: none;') + ]) + ]), + c('&::before', ` + position: absolute; + z-index: 1; + width: calc(100% - 8px); + height: calc(100% - 8px); + background-color: rgb(243, 243, 245, 0.9); + opacity: 0; + transition: opacity .2s var(--bezier); + content: ""; + left: 4px; + top: 4px; + `), + c('&:hover', ` + background-color: initial !important; + `, [ + c('&::before', 'opacity: 1;') + ]) + ]), cM('error-status', [ c('&:hover', ` background-color: var(--item-color-hover-error); `), cB('upload-file-info', [ - cE('name', ` - color: var(--item-text-color-error); - `) + cE('name', [ + c('a', ` + color: var(--item-text-color-error); + `) + ]), + cB('upload-file-info-thumbnail', [ + cE('name', ` + color: var(--item-text-color-error); + `), + cE('image', [ + c('i', ` + color: var(--item-icon-error-color); + `) + ]) + ]) + ]), + cM('picture-card-type, picture-type', ` + border-color: var(--item-icon-error-color); + `) + ]), + cM('success-status', [ + cB('upload-file-info', [ + cB('upload-file-info-thumbnail', [ + cE('image', [ + c('i', ` + color: #2080f0; + `) + ]) + ]) ]) ]), cM('with-url', ` cursor: pointer; `, [ cB('upload-file-info', [ - cE('name', ` - text-decoration: underline; - color: var(--item-text-color-success); - text-decoration-color: var(--item-text-color-success); - `) + cE('name', [ + c('a', ` + text-decoration: underline; + color: var(--item-text-color-success); + text-decoration-color: var(--item-text-color-success); + `) + ]), + cB('upload-file-info-thumbnail', [ + cE('name', ` + text-decoration: underline; + color: var(--item-text-color-success); + text-decoration-color: var(--item-text-color-success); + `) + ]) ]) ]), cB('upload-file-info', ` @@ -114,21 +316,41 @@ export default cB('upload', [ createIconSwitchTransition() ]) ]) - ]) + ]), + cM('picture-type', ` + position: relative; + max-width: 80px; + width: auto; + `), + cM('picture-card-type', ` + z-index: 2; + position: absolute; + width: 100%; + height: 100%; + left: 0; + right: 0; + bottom: 0; + top: 0; + display: flex; + justify-content: center; + align-items: center; + `) ]), cE('name', ` display: flex; align-items: center; text-overflow: ellipsis; overflow: hidden; - text-decoration: underline; - text-decoration-color: #0000; - font-size: var(--font-size); - transition: - color .3s var(--bezier), - text-decoration-color .3s var(--bezier); - color: var(--item-text-color); `, [ + c('a', ` + text-decoration: underline; + text-decoration-color: #0000; + font-size: var(--font-size); + transition: + color .3s var(--bezier), + text-decoration-color .3s var(--bezier); + color: var(--item-text-color); + `), cB('base-icon', ` font-size: 18px; margin-right: 2px; @@ -136,6 +358,15 @@ export default cB('upload', [ color: var(--item-icon-color); `) ]) + ]), + cM('info-status, pending-status, uploading', [ + cM('picture-card-type', ` + transition: + border-color .3s var(--bezier), + background-color .3s var(--bezier); + background-color: var(--dragger-color); + border: var(--dragger-border); + `) ]) ]) ]), @@ -153,6 +384,9 @@ export default cB('upload', [ `), cB('upload-dragger', ` cursor: not-allowed; + `), + cE('picture-card', ` + cursor: not-allowed; `) ]), cM('drag-over', [ diff --git a/src/upload/styles/light.ts b/src/upload/styles/light.ts index b239b8c22..3767a5203 100644 --- a/src/upload/styles/light.ts +++ b/src/upload/styles/light.ts @@ -35,7 +35,8 @@ export const self = (vars: ThemeCommonVars) => { itemTextColorError: errorColor, itemTextColorSuccess: successColor, itemIconColor: iconColor, - itemDisabledOpacity: opacityDisabled + itemDisabledOpacity: opacityDisabled, + itemIconErrorColor: errorColor } } diff --git a/src/upload/tests/Upload.spec.ts b/src/upload/tests/Upload.spec.ts index 621717b4b..ffe2243d2 100644 --- a/src/upload/tests/Upload.spec.ts +++ b/src/upload/tests/Upload.spec.ts @@ -1,5 +1,14 @@ import { mount } from '@vue/test-utils' import { NUpload } from '../index' +import { sleep } from 'seemly' + +const getMockFile = (element: Element, files: File[]): void => { + Object.defineProperty(element, 'files', { + get () { + return files + } + }) +} describe('n-upload', () => { it('should work with import on demand', () => { @@ -23,4 +32,155 @@ describe('n-upload', () => { await wrapper.setProps({ disabled: true }) expect(wrapper.find('.n-upload').classes()).toContain('n-upload--disabled') }) + + it('should work with `on-before-upload` prop', async () => { + const onBeforeUpload = jest.fn(async () => true) + const onChange = jest.fn() + const wrapper = mount(NUpload, { + props: { + onBeforeUpload, + onChange + } + }) + const input = wrapper.find('input') + const fileList = [new File(['index'], 'file.txt')] + + getMockFile(input.element, fileList) + await input.trigger('change') + + expect(onBeforeUpload).toHaveBeenCalled() + expect(onChange).toHaveBeenCalled() + }) + + it('should work with `list-type` prop', async () => { + const wrapper = mount(NUpload, { + props: { + listType: 'text', + action: 'http://www.mocky.io/v2/5e4bafc63100007100d8b70f' + } + }) + const input = wrapper.find('input') + const fileList = [new File(['index'], 'file.txt')] + + getMockFile(input.element, fileList) + await input.trigger('change') + + expect(wrapper.findAll('.n-upload-file--text-type').length).toBe(1) + + await wrapper.setProps({ + listType: 'picture' + }) + expect(wrapper.findAll('.n-upload-file--picture-type').length).toBe(1) + + await wrapper.setProps({ + listType: 'picture-card' + }) + expect(wrapper.findAll('.n-upload-file--picture-card-type').length).toBe(1) + }) + + it('should work with `create-thumbnail-url` prop', async () => { + const createThumbnailUrl = async (): Promise => '/testThumbUrl.png' + const wrapper = mount(NUpload, { + props: { + listType: 'picture', + createThumbnailUrl + } + }) + const input = wrapper.find('input') + const fileList = [new File(['index'], 'file.txt')] + + getMockFile(input.element, fileList) + await input.trigger('change') + await sleep(1000) + expect( + wrapper.find('.n-upload-file-info-thumbnail__image img').attributes('src') + ).toEqual('/testThumbUrl.png') + }) + + it('should work with `on-preview` prop', async () => { + const onPreview = jest.fn() + const wrapper = mount(NUpload, { + props: { + defaultFileList: [ + { + name: 'test.png', + url: '/testUrl.png', + status: 'finished', + id: 'test', + percentage: 100, + file: null + } + ], + onPreview + } + }) + const urlName = wrapper.findAll('.n-upload-file-info-thumbnail__name')[0] + await urlName.trigger('click') + + expect(onPreview).toHaveBeenCalled() + }) + + it('should work with `show-remove-button` and `on-remove` prop', async () => { + const onRemove = jest.fn() + const wrapper = mount(NUpload, { + props: { + defaultFileList: [ + { + name: 'test.png', + url: '/testUrl.png', + status: 'finished', + id: 'test', + percentage: 100, + file: null + } + ], + onRemove, + showRemoveButton: false + } + }) + let button = wrapper.find('.n-button--default-type') + expect(button.exists()).not.toBe(true) + + await wrapper.setProps({ + showRemoveButton: true + }) + + button = wrapper.find('.n-button--default-type') + expect(button.exists()).toBe(true) + + await button.trigger('click') + expect(onRemove).toHaveBeenCalled() + }) + + it('should work with `show-cancel-button` and `on-remove` prop', async () => { + const onRemove = jest.fn() + const wrapper = mount(NUpload, { + props: { + defaultFileList: [ + { + name: 'test.png', + url: '/testUrl.png', + status: 'error', + id: 'test', + percentage: 0, + file: null + } + ], + onRemove, + showCancelButton: false + } + }) + let button = wrapper.findAll('.n-button--error-type') + expect(button.length).toEqual(1) + + await wrapper.setProps({ + showCancelButton: true + }) + + button = wrapper.findAll('.n-button--error-type') + expect(button.length).toEqual(2) + + await button[0].trigger('click') + expect(onRemove).toHaveBeenCalled() + }) })