diff --git a/components/__tests__/util/domHook.js b/components/__tests__/util/domHook.js new file mode 100644 index 0000000000..1c920fb223 --- /dev/null +++ b/components/__tests__/util/domHook.js @@ -0,0 +1,54 @@ +export function spyElementPrototypes(Element, properties) { + const propNames = Object.keys(properties); + const originDescriptors = {}; + + propNames.forEach(propName => { + const originDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, propName); + originDescriptors[propName] = originDescriptor; + + const spyProp = properties[propName]; + + if (typeof spyProp === 'function') { + // If is a function + Element.prototype[propName] = function spyFunc(...args) { + return spyProp.call(this, originDescriptor, ...args); + }; + } else { + // Otherwise tread as a property + Object.defineProperty(Element.prototype, propName, { + ...spyProp, + set(value) { + if (spyProp.set) { + return spyProp.set.call(this, originDescriptor, value); + } + return originDescriptor.set(value); + }, + get() { + if (spyProp.get) { + return spyProp.get.call(this, originDescriptor); + } + return originDescriptor.get(); + }, + }); + } + }); + + return { + mockRestore() { + propNames.forEach(propName => { + const originDescriptor = originDescriptors[propName]; + if (typeof originDescriptor === 'function') { + Element.prototype[propName] = originDescriptor; + } else { + Object.defineProperty(Element.prototype, propName, originDescriptor); + } + }); + }, + }; +} + +export function spyElementPrototype(Element, propName, property) { + return spyElementPrototypes(Element, { + [propName]: property, + }); +} diff --git a/components/upload/Upload.tsx b/components/upload/Upload.tsx index de7d3f5551..b082f9986b 100644 --- a/components/upload/Upload.tsx +++ b/components/upload/Upload.tsx @@ -238,12 +238,13 @@ class Upload extends React.Component { }; renderUploadList = (locale: UploadLocale) => { - const { showUploadList, listType, onPreview } = this.props; + const { showUploadList, listType, onPreview, previewFile } = this.props; const { showRemoveIcon, showPreviewIcon } = showUploadList as any; return ( { - if (!url) { - return ''; - } - const temp = url.split('/'); - const filename = temp[temp.length - 1]; - const filenameWithoutSuffix = filename.split(/#|\?/)[0]; - return (/\.[^./\\]*$/.exec(filenameWithoutSuffix) || [''])[0]; -}; -const isImageFileType = (type: string): boolean => !!type && type.indexOf('image/') === 0; -const isImageUrl = (file: UploadFile): boolean => { - if (isImageFileType(file.type)) { - return true; - } - const url: string = (file.thumbUrl || file.url) as string; - const extension = extname(url); - if (/^data:image\//.test(url) || /(webp|svg|png|gif|jpg|jpeg|bmp|dpg)$/i.test(extension)) { - return true; - } else if (/^data:/.test(url)) { - // other file types of base64 - return false; - } else if (extension) { - // other file types which have extension - return false; - } - return true; -}; - export default class UploadList extends React.Component { static defaultProps = { listType: 'text' as UploadListType, // or picture @@ -44,6 +17,7 @@ export default class UploadList extends React.Component { }, showRemoveIcon: true, showPreviewIcon: true, + previewFile: previewImage, }; handleClose = (file: UploadFile) => { @@ -62,21 +36,12 @@ export default class UploadList extends React.Component { return onPreview(file); }; - // https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL - previewFile = (file: File | Blob, callback: Function) => { - if (!isImageFileType(file.type)) { - return callback(''); - } - const reader = new FileReader(); - reader.onloadend = () => callback(reader.result); - reader.readAsDataURL(file); - }; - componentDidUpdate() { - if (this.props.listType !== 'picture' && this.props.listType !== 'picture-card') { + const { listType, items, previewFile } = this.props; + if (listType !== 'picture' && listType !== 'picture-card') { return; } - (this.props.items || []).forEach(file => { + (items || []).forEach(file => { if ( typeof document === 'undefined' || typeof window === 'undefined' || @@ -88,10 +53,13 @@ export default class UploadList extends React.Component { return; } file.thumbUrl = ''; - this.previewFile(file.originFileObj, (previewDataUrl: string) => { - file.thumbUrl = previewDataUrl; - this.forceUpdate(); - }); + if (previewFile) { + previewFile(file.originFileObj).then((previewDataUrl: string) => { + // Need append '' to avoid dead loop + file.thumbUrl = previewDataUrl || ''; + this.forceUpdate(); + }); + } }); } diff --git a/components/upload/__tests__/__snapshots__/demo.test.js.snap b/components/upload/__tests__/__snapshots__/demo.test.js.snap index 865868ba06..676bb4156a 100644 --- a/components/upload/__tests__/__snapshots__/demo.test.js.snap +++ b/components/upload/__tests__/__snapshots__/demo.test.js.snap @@ -631,6 +631,21 @@ exports[`renders ./components/upload/demo/picture-style.md correctly 1`] = ` `; +exports[`renders ./components/upload/demo/preview-file.md correctly 1`] = ` +
+ +
+
+ +
+`; + exports[`renders ./components/upload/demo/upload-manually.md correctly 1`] = `
{ + // jsdom not support `createObjectURL` yet. Let's handle this. + const originCreateObjectURL = window.URL.createObjectURL; + window.URL.createObjectURL = jest.fn(() => ''); + + // Mock dom + let size = { width: 0, height: 0 }; + function setSize(width, height) { + size = { width, height }; + } + const imageSpy = spyElementPrototypes(Image, { + src: { + set() { + if (this.onload) { + this.onload(); + } + }, + }, + width: { + get: () => size.width, + }, + height: { + get: () => size.height, + }, + }); + + let drawImageCallback = null; + function hookDrawImageCall(callback) { + drawImageCallback = callback; + } + const canvasSpy = spyElementPrototypes(HTMLCanvasElement, { + getContext: () => ({ + drawImage: (...args) => { + if (drawImageCallback) drawImageCallback(...args); + }, + }), + + toDataURL: () => 'data:image/png;base64,', + }); + + // HTMLCanvasElement.prototype + beforeEach(() => setup()); - afterEach(() => teardown()); + afterEach(() => { + teardown(); + drawImageCallback = null; + }); + + afterAll(() => { + window.URL.createObjectURL = originCreateObjectURL; + imageSpy.mockRestore(); + canvasSpy.mockRestore(); + }); // https://github.com/ant-design/ant-design/issues/4653 it('should use file.thumbUrl for in priority', () => { @@ -198,20 +249,45 @@ describe('Upload List', () => { expect(handleChange.mock.calls.length).toBe(2); }); - it('should generate thumbUrl from file', async () => { - const handlePreview = jest.fn(); - const newFileList = [...fileList]; - const newFile = { ...fileList[0], uid: '-3', originFileObj: new File([], 'xxx.png') }; - delete newFile.thumbUrl; - newFileList.push(newFile); - const wrapper = mount( - - - , - ); - wrapper.setState({}); - await delay(0); - expect(wrapper.state().fileList[2].thumbUrl).not.toBe(undefined); + describe('should generate thumbUrl from file', () => { + [ + { width: 100, height: 200, name: 'height large than width' }, + { width: 200, height: 100, name: 'width large than height' }, + ].forEach(({ width, height, name }) => { + it(name, async () => { + setSize(width, height); + const onDrawImage = jest.fn(); + hookDrawImageCall(onDrawImage); + + const handlePreview = jest.fn(); + const newFileList = [...fileList]; + const newFile = { + ...fileList[0], + uid: '-3', + originFileObj: new File([], 'xxx.png', { type: 'image/png' }), + }; + delete newFile.thumbUrl; + newFileList.push(newFile); + const wrapper = mount( + + + , + ); + wrapper.setState({}); + await delay(0); + + expect(wrapper.state().fileList[2].thumbUrl).not.toBe(undefined); + expect(onDrawImage).toHaveBeenCalled(); + + // Offset check + const [, offsetX, offsetY] = onDrawImage.mock.calls[0]; + if (width > height) { + expect(offsetX < 0).toBeTruthy(); + } else { + expect(offsetY < 0).toBeTruthy(); + } + }); + }); }); it('should non-image format file preview', () => { @@ -356,15 +432,13 @@ describe('Upload List', () => { expect(wrapper.handlePreview()).toBe(undefined); }); - it('previewFile should work correctly', () => { - const callback = jest.fn(); + it('previewFile should work correctly', async () => { const file = new File([''], 'test.txt', { type: 'text/plain' }); const items = [{ uid: 'upload-list-item', url: '' }]; const wrapper = mount( , ).instance(); - wrapper.previewFile(file, callback); - expect(callback).toHaveBeenCalled(); + return wrapper.props.previewFile(file); }); it('extname should work correctly when url not exists', () => { @@ -404,7 +478,7 @@ describe('Upload List', () => { expect(onPreview).toHaveBeenCalled(); }); - it('upload image file should be converted to the base64', done => { + it('upload image file should be converted to the base64', async () => { const mockFile = new File([''], 'foo.png', { type: 'image/png', }); @@ -413,14 +487,12 @@ describe('Upload List', () => { , ); const instance = wrapper.instance(); - const callback = dataUrl => { + return instance.props.previewFile(mockFile).then(dataUrl => { expect(dataUrl).toEqual('data:image/png;base64,'); - done(); - }; - instance.previewFile(mockFile, callback); + }); }); - it("upload non image file shouldn't be converted to the base64", () => { + it("upload non image file shouldn't be converted to the base64", async () => { const mockFile = new File([''], 'foo.7z', { type: 'application/x-7z-compressed', }); @@ -429,8 +501,33 @@ describe('Upload List', () => { , ); const instance = wrapper.instance(); - const callback = jest.fn(); - instance.previewFile(mockFile, callback); - expect(callback).toHaveBeenCalledWith(''); + return instance.props.previewFile(mockFile).then(dataUrl => { + expect(dataUrl).toBe(''); + }); + }); + + it('customize previewFile support', async () => { + const mockThumbnail = 'mock-image'; + const previewFile = jest.fn(() => { + return Promise.resolve(mockThumbnail); + }); + const file = { + ...fileList[0], + originFileObj: new File([], 'xxx.png'), + }; + delete file.thumbUrl; + + const wrapper = mount( + + + +
, + mountNode +); +```` diff --git a/components/upload/index.en-US.md b/components/upload/index.en-US.md index 12ad320361..58045a8e56 100644 --- a/components/upload/index.en-US.md +++ b/components/upload/index.en-US.md @@ -16,28 +16,29 @@ Uploading is the process of publishing information (web pages, text, pictures, v ## API -| Property | Description | Type | Default | -| -------- | ----------- | ---- | ------- | -| accept | File types that can be accepted. See [input accept Attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept) | string | - | -| action | Uploading URL | string\|(file) => `Promise` | - | -| directory | support upload whole directory ([caniuse](https://caniuse.com/#feat=input-file-directory)) | boolean | false | -| beforeUpload | Hook function which will be executed before uploading. Uploading will be stopped with `false` or a rejected Promise returned. **Warning:this function is not supported in IE9**。 | (file, fileList) => `boolean | Promise` | - | -| customRequest | override for the default xhr behavior allowing for additional customization and ability to implement your own XMLHttpRequest | Function | - | -| data | Uploading params or function which can return uploading params. | object\|function(file) | - | -| defaultFileList | Default list of files that have been uploaded. | object\[] | - | -| disabled | disable upload button | boolean | false | -| fileList | List of files that have been uploaded (controlled). Here is a common issue [#2423](https://github.com/ant-design/ant-design/issues/2423) when using it | object\[] | - | -| headers | Set request headers, valid above IE10. | object | - | -| listType | Built-in stylesheets, support for three types: `text`, `picture` or `picture-card` | string | 'text' | -| multiple | Whether to support selected multiple file. `IE10+` supported. You can select multiple files with CTRL holding down while multiple is set to be true | boolean | false | -| name | The name of uploading file | string | 'file' | -| showUploadList | Whether to show default upload list, could be an object to specify `showPreviewIcon` and `showRemoveIcon` individually | Boolean or { showPreviewIcon?: boolean, showRemoveIcon?: boolean } | true | -| supportServerRender | Need to be turned on while the server side is rendering | boolean | false | -| withCredentials | ajax upload with cookie sent | boolean | false | -| openFileDialogOnClick | click open file dialog | boolean | true | -| onChange | A callback function, can be executed when uploading state is changing, see [onChange](#onChange) | Function | - | -| onPreview | A callback function, will be executed when file link or preview icon is clicked | Function(file) | - | -| onRemove | A callback function, will be executed when removing file button is clicked, remove event will be prevented when return value is `false` or a Promise which resolve(false) or reject | Function(file): `boolean | Promise` | - | +| Property | Description | Type | Default | Version | +| -------- | ----------- | ---- | ------- | ------- | +| accept | File types that can be accepted. See [input accept Attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept) | string | - | | +| action | Uploading URL | string\|(file) => `Promise` | - | | +| directory | support upload whole directory ([caniuse](https://caniuse.com/#feat=input-file-directory)) | boolean | false | | +| beforeUpload | Hook function which will be executed before uploading. Uploading will be stopped with `false` or a rejected Promise returned. **Warning:this function is not supported in IE9**。 | (file, fileList) => `boolean | Promise` | - | | +| customRequest | override for the default xhr behavior allowing for additional customization and ability to implement your own XMLHttpRequest | Function | - | | +| data | Uploading params or function which can return uploading params. | object\|function(file) | - | | +| defaultFileList | Default list of files that have been uploaded. | object\[] | - | | +| disabled | disable upload button | boolean | false | | +| fileList | List of files that have been uploaded (controlled). Here is a common issue [#2423](https://github.com/ant-design/ant-design/issues/2423) when using it | object\[] | - | | +| headers | Set request headers, valid above IE10. | object | - | | +| listType | Built-in stylesheets, support for three types: `text`, `picture` or `picture-card` | string | 'text' | | +| multiple | Whether to support selected multiple file. `IE10+` supported. You can select multiple files with CTRL holding down while multiple is set to be true | boolean | false | | +| name | The name of uploading file | string | 'file' | | +| previewFile | Customize preview file logic | (file: File \| Blob) => Promise | - | 3.17.0 | +| showUploadList | Whether to show default upload list, could be an object to specify `showPreviewIcon` and `showRemoveIcon` individually | Boolean or { showPreviewIcon?: boolean, showRemoveIcon?: boolean } | true | | +| supportServerRender | Need to be turned on while the server side is rendering | boolean | false | | +| withCredentials | ajax upload with cookie sent | boolean | false | | +| openFileDialogOnClick | click open file dialog | boolean | true | | +| onChange | A callback function, can be executed when uploading state is changing, see [onChange](#onChange) | Function | - | | +| onPreview | A callback function, will be executed when file link or preview icon is clicked | Function(file) | - | | +| onRemove | A callback function, will be executed when removing file button is clicked, remove event will be prevented when return value is `false` or a Promise which resolve(false) or reject | Function(file): `boolean | Promise` | - | | ### onChange diff --git a/components/upload/index.zh-CN.md b/components/upload/index.zh-CN.md index f83c739a93..d2325548ae 100644 --- a/components/upload/index.zh-CN.md +++ b/components/upload/index.zh-CN.md @@ -17,28 +17,29 @@ title: Upload ## API -| 参数 | 说明 | 类型 | 默认值 | -| --- | --- | --- | --- | -| accept | 接受上传的文件类型, 详见 [input accept Attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept) | string | 无 | -| action | 上传的地址 | string\|(file) => `Promise` | 无 | -| directory | 支持上传文件夹([caniuse](https://caniuse.com/#feat=input-file-directory))| boolean | false | -| beforeUpload | 上传文件之前的钩子,参数为上传的文件,若返回 `false` 则停止上传。支持返回一个 Promise 对象,Promise 对象 reject 时则停止上传,resolve 时开始上传( resolve 传入 `File` 或 `Blob` 对象则上传 resolve 传入对象)。**注意:IE9 不支持该方法**。 | (file, fileList) => `boolean | Promise` | 无 | -| customRequest | 通过覆盖默认的上传行为,可以自定义自己的上传实现 | Function | 无 | -| data | 上传所需参数或返回上传参数的方法 | object\|(file) => object | 无 | -| defaultFileList | 默认已经上传的文件列表 | object\[] | 无 | -| disabled | 是否禁用 | boolean | false | -| fileList | 已经上传的文件列表(受控),使用此参数时,如果遇到 `onChange` 只调用一次的问题,请参考 [#2423](https://github.com/ant-design/ant-design/issues/2423) | object\[] | 无 | -| headers | 设置上传的请求头部,IE10 以上有效 | object | 无 | -| listType | 上传列表的内建样式,支持三种基本样式 `text`, `picture` 和 `picture-card` | string | 'text' | -| multiple | 是否支持多选文件,`ie10+` 支持。开启后按住 ctrl 可选择多个文件 | boolean | false | -| name | 发到后台的文件参数名 | string | 'file' | -| showUploadList | 是否展示文件列表, 可设为一个对象,用于单独设定 `showPreviewIcon` 和 `showRemoveIcon` | Boolean or { showPreviewIcon?: boolean, showRemoveIcon?: boolean } | true | -| supportServerRender | 服务端渲染时需要打开这个 | boolean | false | -| withCredentials | 上传请求时是否携带 cookie | boolean | false | -| openFileDialogOnClick | 点击打开文件对话框 | boolean | true | -| onChange | 上传文件改变时的状态,详见 [onChange](#onChange) | Function | 无 | -| onPreview | 点击文件链接或预览图标时的回调 | Function(file) | 无 | -| onRemove   | 点击移除文件时的回调,返回值为 false 时不移除。支持返回一个 Promise 对象,Promise 对象 resolve(false) 或 reject 时不移除。               | Function(file): `boolean | Promise` | 无   | +| 参数 | 说明 | 类型 | 默认值 | 版本 | +| --- | --- | --- | --- | --- | +| accept | 接受上传的文件类型, 详见 [input accept Attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept) | string | 无 | | +| action | 上传的地址 | string\|(file) => `Promise` | 无 | | +| directory | 支持上传文件夹([caniuse](https://caniuse.com/#feat=input-file-directory))| boolean | false | | +| beforeUpload | 上传文件之前的钩子,参数为上传的文件,若返回 `false` 则停止上传。支持返回一个 Promise 对象,Promise 对象 reject 时则停止上传,resolve 时开始上传( resolve 传入 `File` 或 `Blob` 对象则上传 resolve 传入对象)。**注意:IE9 不支持该方法**。 | (file, fileList) => `boolean | Promise` | 无 | | +| customRequest | 通过覆盖默认的上传行为,可以自定义自己的上传实现 | Function | 无 | | +| data | 上传所需参数或返回上传参数的方法 | object\|(file) => object | 无 | | +| defaultFileList | 默认已经上传的文件列表 | object\[] | 无 | | +| disabled | 是否禁用 | boolean | false | | +| fileList | 已经上传的文件列表(受控),使用此参数时,如果遇到 `onChange` 只调用一次的问题,请参考 [#2423](https://github.com/ant-design/ant-design/issues/2423) | object\[] | 无 | | +| headers | 设置上传的请求头部,IE10 以上有效 | object | 无 | | +| listType | 上传列表的内建样式,支持三种基本样式 `text`, `picture` 和 `picture-card` | string | 'text' | | +| multiple | 是否支持多选文件,`ie10+` 支持。开启后按住 ctrl 可选择多个文件 | boolean | false | | +| name | 发到后台的文件参数名 | string | 'file' | | +| previewFile | 自定义文件预览逻辑 | (file: File \| Blob) => Promise | 无 | 3.17.0 | +| showUploadList | 是否展示文件列表, 可设为一个对象,用于单独设定 `showPreviewIcon` 和 `showRemoveIcon` | Boolean or { showPreviewIcon?: boolean, showRemoveIcon?: boolean } | true | | +| supportServerRender | 服务端渲染时需要打开这个 | boolean | false | | +| withCredentials | 上传请求时是否携带 cookie | boolean | false | | +| openFileDialogOnClick | 点击打开文件对话框 | boolean | true | | +| onChange | 上传文件改变时的状态,详见 [onChange](#onChange) | Function | 无 | | +| onPreview | 点击文件链接或预览图标时的回调 | Function(file) | 无 | | +| onRemove   | 点击移除文件时的回调,返回值为 false 时不移除。支持返回一个 Promise 对象,Promise 对象 resolve(false) 或 reject 时不移除。               | Function(file): `boolean | Promise` | 无   | | ### onChange diff --git a/components/upload/interface.tsx b/components/upload/interface.tsx index bc372c713b..6545ed3bdb 100755 --- a/components/upload/interface.tsx +++ b/components/upload/interface.tsx @@ -50,6 +50,8 @@ export interface UploadLocale { export type UploadType = 'drag' | 'select'; export type UploadListType = 'text' | 'picture' | 'picture-card'; +type PreviewFileHandler = (file: File | Blob) => PromiseLike; + export interface UploadProps { type?: UploadType; name?: string; @@ -77,6 +79,7 @@ export interface UploadProps { openFileDialogOnClick?: boolean; locale?: UploadLocale; id?: string; + previewFile?: PreviewFileHandler; } export interface UploadState { @@ -94,4 +97,5 @@ export interface UploadListProps { showRemoveIcon?: boolean; showPreviewIcon?: boolean; locale: UploadLocale; + previewFile?: PreviewFileHandler; } diff --git a/components/upload/utils.tsx b/components/upload/utils.tsx index 8a21af37fb..f90d4f61a5 100644 --- a/components/upload/utils.tsx +++ b/components/upload/utils.tsx @@ -56,3 +56,73 @@ export function removeFileItem(file: UploadFile, fileList: UploadFile[]) { } return removed; } + +// ==================== Default Image Preview ==================== +const extname = (url: string) => { + if (!url) { + return ''; + } + const temp = url.split('/'); + const filename = temp[temp.length - 1]; + const filenameWithoutSuffix = filename.split(/#|\?/)[0]; + return (/\.[^./\\]*$/.exec(filenameWithoutSuffix) || [''])[0]; +}; +const isImageFileType = (type: string): boolean => !!type && type.indexOf('image/') === 0; +export const isImageUrl = (file: UploadFile): boolean => { + if (isImageFileType(file.type)) { + return true; + } + const url: string = (file.thumbUrl || file.url) as string; + const extension = extname(url); + if (/^data:image\//.test(url) || /(webp|svg|png|gif|jpg|jpeg|bmp|dpg)$/i.test(extension)) { + return true; + } else if (/^data:/.test(url)) { + // other file types of base64 + return false; + } else if (extension) { + // other file types which have extension + return false; + } + return true; +}; + +const MEASURE_SIZE = 200; +export function previewImage(file: File | Blob): Promise { + return new Promise(resolve => { + if (!isImageFileType(file.type)) { + resolve(''); + return; + } + + const canvas = document.createElement('canvas'); + canvas.width = MEASURE_SIZE; + canvas.height = MEASURE_SIZE; + canvas.style.cssText = `position: fixed; left: 0; top: 0; width: ${MEASURE_SIZE}px; height: ${MEASURE_SIZE}px; z-index: 9999; display: none;`; + document.body.appendChild(canvas); + const ctx = canvas.getContext('2d'); + const img = new Image(); + img.onload = function() { + const { width, height } = img; + + let drawWidth = MEASURE_SIZE; + let drawHeight = MEASURE_SIZE; + let offsetX = 0; + let offsetY = 0; + + if (width < height) { + drawHeight = height * (MEASURE_SIZE / width); + offsetY = -(drawHeight - drawWidth) / 2; + } else { + drawWidth = width * (MEASURE_SIZE / height); + offsetX = -(drawWidth - drawHeight) / 2; + } + + ctx!.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); + const dataURL = canvas.toDataURL(); + document.body.removeChild(canvas); + + resolve(dataURL); + }; + img.src = window.URL.createObjectURL(file); + }); +}