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 <kev1nzh@app-ark.com>
Co-authored-by: 07akioni <07akioni2@gmail.com>
This commit is contained in:
Kev1nzh 2021-08-31 01:12:38 +08:00 committed by GitHub
parent c791d5a96c
commit da1646570d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1195 additions and 67 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,21 @@
import { h, defineComponent } from 'vue'
export default defineComponent({
name: 'File',
render () {
return (
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path>
</g>
</svg>
)
}
})

View File

@ -0,0 +1,23 @@
import { h, defineComponent } from 'vue'
export default defineComponent({
name: 'Photo',
render () {
return (
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 8h.01"></path>
<rect x="4" y="4" width="16" height="16" rx="3"></rect>
<path d="M4 15l4-4a3 5 0 0 1 3 0l5 5"></path>
<path d="M14 14l1-1a3 5 0 0 1 3 0l2 2"></path>
</g>
</svg>
)
}
})

View File

@ -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'

View File

@ -24,6 +24,9 @@ interface imgProps {
usemap?: string
width?: number
}
export interface ImageInst {
handleClick: () => void
}
const imageProps = {
alt: String,

View File

@ -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<UploadFile>` | `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<UploadFile>, 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<UploadFile> }) => boolean \| Promise<boolean> \| 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<UploadFile> }) => (Promise<boolean \| void> \| 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<thumbnailUrl: string>` | `undefined` | Customize file thumbnails. |
### UploadFile Type

View File

@ -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
<n-upload
action="http://www.mocky.io/v2/5e4bafc63100007100d8b70f"
:default-file-list="fileList"
list-type="picture-card"
>
<div :style="style">
<n-icon size="30">
<Ios-add />
</n-icon>
<span>Upload</span>
</div>
</n-upload>
<n-divider />
<n-upload
action="http://www.mocky.io/v2/5e4bafc63100007100d8b70f"
:default-file-list="previewFileList"
list-type="picture-card"
@preview="handlePreview"
>
<div :style="style">
<n-icon size="30">
<Ios-add />
</n-icon>
<span>Upload</span>
</div>
</n-upload>
<n-modal
v-model:show="showModal"
preset="card"
:style="modalStyle"
title="Coooooool Photo"
size="huge"
:bordered="false"
>
<img :src="previewImageUrl" :style="imgStyle" />
</n-modal>
```
```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'
}
]
}
}
}
```

View File

@ -0,0 +1,61 @@
# Thumbnail File List
`list-type = 'picture'`
You can use `preview-file` to customize the thumbnails of the file.
```html
<n-upload
action="http://www.mocky.io/v2/5e4bafc63100007100d8b70f"
:default-file-list="fileList"
list-type="picture"
:createThumbnailUrl="createThumbnailUrl"
>
<n-button>Upload</n-button>
</n-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'
}
}
}
})
```

View File

@ -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<UploadFile>` | `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<UploadFile>, 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> \| boolean \| void)` | `undefined` | 文件上传之前的回调,返回 `false`、`Promise resolve false`、`Promise rejected` 时会取消本次上传 |
| on-preview | `(file: FileInfo) => void` | `undefined` | 点击文件链接或预览按钮的回调函数 |
| create-thumbnail-url | `(file: File) => Promise<thumbnailUrl: string>` | `undefined` | 自定义文件缩略图 |
### UploadFile Type
@ -71,6 +77,6 @@ before-upload
### Upload Dragger Slots
| 名称 | 参数 | 说明 |
| ------- | ---- | ------------------------------------ |
| 名称 | 参数 | 说明 |
| ------- | ---- | --------------------------------------------- |
| default | `()` | 上传拖动器的内容,使用可参考[拖拽上传](#drag) |

View File

@ -0,0 +1,119 @@
# 照片墙
`list-type = 'picture-card'`
照片墙中的预览会默认调用内部组件,你也可以使用 `on-preview` 自定义展示上传文件的方法
```html
<n-upload
action="http://www.mocky.io/v2/5e4bafc63100007100d8b70f"
:default-file-list="fileList"
list-type="picture-card"
>
<div :style="style">
<n-icon size="30">
<Ios-add />
</n-icon>
<span>Upload</span>
</div>
</n-upload>
<n-divider />
<n-upload
action="http://www.mocky.io/v2/5e4bafc63100007100d8b70f"
:default-file-list="previewFileList"
list-type="picture-card"
@preview="handlePreview"
>
<div :style="style">
<n-icon size="30">
<Ios-add />
</n-icon>
<span>Upload</span>
</div>
</n-upload>
<n-modal
v-model:show="showModal"
preset="card"
:style="modalStyle"
title="这是一张超酷的图片"
size="huge"
:bordered="false"
>
<img :src="previewImageUrl" :style="imgStyle" />
</n-modal>
```
```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'
}
]
}
}
}
```

View File

@ -0,0 +1,59 @@
# 缩略图文件列表
`list-type = 'picture'`
你可以使用 `preview-file`自定义文件的缩略图
```html
<n-upload
action="http://www.mocky.io/v2/5e4bafc63100007100d8b70f"
:default-file-list="fileList"
list-type="picture"
:create-thumbnail-url="createThumbnailUrl"
>
<n-button>上传文件</n-button>
</n-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: '我是上传出错的普通文件.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'
}
}
}
})
```

View File

@ -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<listType>,
default: 'text'
},
onPreview: Function as PropType<OnPreview>,
createThumbnailUrl: Function as PropType<CreateThumbnailUrl>
} 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<typeof uploadProps>
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<string> {
const { createThumbnailUrl } = props
return createThumbnailUrl
? await createThumbnailUrl(file.file as File)
: await previewImage(file.file as File)
}
async function previewImage (file: File): Promise<string> {
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 = (
<div
class={[
`${mergedClsPrefix}-upload__trigger`,
this.listType === 'picture-card' &&
`${mergedClsPrefix}-upload__trigger--picture-card`
]}
onClick={this.handleTriggerClick}
onDrop={this.handleTriggerDrop}
onDragover={this.handleTriggerDragOver}
onDragenter={this.handleTriggerDragEnter}
onDragleave={this.handleTriggerDragLeave}
>
{this.$slots}
</div>
)
const uploadFileList = (
<NFadeInExpandTransition group>
{{
default: () =>
this.mergedFileList.map((file) => (
<NUploadFile
clsPrefix={mergedClsPrefix}
key={file.id}
file={file}
listType={this.listType}
/>
))
}}
</NFadeInExpandTransition>
)
return (
<div
class={[
@ -516,33 +634,22 @@ export default defineComponent({
multiple={this.multiple}
onChange={this.handleFileInputChange}
/>
<div
class={`${mergedClsPrefix}-upload__trigger`}
onClick={this.handleTriggerClick}
onDrop={this.handleTriggerDrop}
onDragover={this.handleTriggerDragOver}
onDragenter={this.handleTriggerDragEnter}
onDragleave={this.handleTriggerDragLeave}
>
{this.$slots}
</div>
{this.listType !== 'picture-card' && uploadTrigger}
{this.showFileList && (
<div
class={`${mergedClsPrefix}-upload-file-list`}
style={this.fileListStyle}
>
<NFadeInExpandTransition group>
{{
default: () =>
this.mergedFileList.map((file) => (
<NUploadFile
clsPrefix={mergedClsPrefix}
key={file.id}
file={file}
/>
))
}}
</NFadeInExpandTransition>
{this.listType === 'picture-card' ? (
<NImageGroup>
{{
default: () => uploadFileList
}}
</NImageGroup>
) : (
uploadFileList
)}
{this.listType === 'picture-card' && uploadTrigger}
</div>
)}
</div>

View File

@ -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<FileInfo>,
required: true
},
listType: {
type: String as PropType<listType>,
default: 'text'
}
},
setup (props) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const NUpload = inject(uploadInjectionKey)!
const imageRef = ref<ImageInst | null>(null)
const thumbnailUrl = ref<string>('')
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<void> => {
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 => (
<span class={`${clsPrefix}-upload-file-info-thumbnail__image`}>
<NBaseIcon clsPrefix={clsPrefix}>{{ default: () => icon }}</NBaseIcon>
</span>
)
// if there is text list type, show file icon
let icon = (
<NBaseIcon clsPrefix={clsPrefix}>
{{ default: () => <AttachIcon /> }}
</NBaseIcon>
)
if (this.listType === 'picture' || this.listType === 'picture-card') {
if (this.file.status === 'uploading') {
icon =
this.listType === 'picture-card' ? (
<span>Upload..</span>
) : (
<NBaseLoading
key="loading"
clsPrefix={clsPrefix}
class={`${clsPrefix}-upload-file-info-thumbnail__spin`}
strokeWidth={20}
/>
)
} else {
icon = !this.isImageUrl(this.file) ? (
fileIcon(<FileIcon />)
) : (this.file.url || this.thumbnailUrl) &&
this.file.status !== 'error' ? (
<a
rel="noopener noreferer"
target="_blank"
href={this.file.url || undefined}
class={`${clsPrefix}-upload-file-info-thumbnail__image`}
onClick={(e) => this.handlePreviewClick(e)}
>
{this.listType === 'picture-card' ? (
<NImage
src={this.thumbnailUrl || this.file.url || undefined}
alt={this.file.name}
ref="imageRef"
></NImage>
) : (
<img
src={this.thumbnailUrl || this.file.url || undefined}
alt={this.file.name}
/>
)}
</a>
) : (
fileIcon(<PhotoIcon />)
)
}
}
return (
<a
ref="noopener noreferer"
target="_blank"
href={this.file.url || undefined}
<div
class={[
`${clsPrefix}-upload-file`,
`${clsPrefix}-upload-file--${this.progressStatus}-status`,
this.file.url && `${clsPrefix}-upload-file--with-url`
this.file.url &&
this.file.status !== 'error' &&
this.listType !== 'picture-card' &&
`${clsPrefix}-upload-file--with-url`,
`${clsPrefix}-upload-file--${this.listType}-type`
]}
>
<div class={`${clsPrefix}-upload-file-info`}>
<div class={`${clsPrefix}-upload-file-info__name`}>
<NBaseIcon clsPrefix={clsPrefix}>
{{ default: () => <AttachIcon /> }}
</NBaseIcon>
{this.file.name}
<div
class={
this.listType === 'picture' || this.listType === 'picture-card'
? `${clsPrefix}-upload-file-info-thumbnail`
: `${clsPrefix}-upload-file-info__name`
}
>
{icon}
{(this.listType !== 'picture' &&
this.listType !== 'picture-card') ||
(this.file.url && this.file.status !== 'error') ? (
<a
rel="noopener noreferer"
target="_blank"
href={this.file.url || undefined}
class={[
...thumbnailNameClass,
`${clsPrefix}-upload-file-info-thumbnail__hide`
]}
onClick={(e) => this.handlePreviewClick(e)}
>
{this.file.name}
</a>
) : (
<span
class={[
...thumbnailNameClass,
(this.file.url ||
this.thumbnailUrl ||
this.file.status === 'uploading') &&
`${clsPrefix}-upload-file-info-thumbnail__hide`
]}
onClick={(e) => this.handlePreviewClick(e)}
>
{this.file.name}
</span>
)}
</div>
<div class={`${clsPrefix}-upload-file-info__action`}>
<div
class={[
`${clsPrefix}-upload-file-info__action`,
`${clsPrefix}-upload-file-info__action--${this.listType}-type`
]}
>
{this.showPreivewButton ? (
<NButton
key="preview"
text
type={this.buttonType}
onClick={this.handlePreviewClick}
theme={mergedTheme.peers.Button}
themeOverrides={mergedTheme.peerOverrides.Button}
>
{{
icon: () => (
<NBaseIcon clsPrefix={clsPrefix}>
{{ default: () => <EyeIcon /> }}
</NBaseIcon>
)
}}
</NButton>
) : null}
{(this.showRemoveButton || this.showCancelButton) &&
!this.disabled && (
<NButton
@ -230,7 +423,7 @@ export default defineComponent({
percentage={this.file.percentage || 0}
status={this.progressStatus}
/>
</a>
</div>
)
}
})

View File

@ -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<string, XMLHttpRequest>
submit: (fileId?: string) => void
doChange: DoChange
isImageUrl: (file: FileInfo) => boolean
showPreivewButtonRef: Ref<boolean>
onPreviewRef: Ref<OnPreview | undefined>
getFileThumbnail: (file: FileInfo) => Promise<string>
}
export const uploadInjectionKey: InjectionKey<UploadInjection> =
@ -85,3 +91,9 @@ export type OnBeforeUpload = (data: {
file: FileInfo
fileList: FileInfo[]
}) => Promise<unknown>
export type listType = 'text' | 'picture' | 'picture-card'
export type OnPreview = (file: FileInfo) => void
export type CreateThumbnailUrl = (file: File) => Promise<string>

View File

@ -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', [

View File

@ -35,7 +35,8 @@ export const self = (vars: ThemeCommonVars) => {
itemTextColorError: errorColor,
itemTextColorSuccess: successColor,
itemIconColor: iconColor,
itemDisabledOpacity: opacityDisabled
itemDisabledOpacity: opacityDisabled,
itemIconErrorColor: errorColor
}
}

View File

@ -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<string> => '/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()
})
})