diff --git a/src/packages/uploader/__tests__/uploader.spec.tsx b/src/packages/uploader/__tests__/uploader.spec.tsx index 589c0ed84d..c80b2f7ebe 100644 --- a/src/packages/uploader/__tests__/uploader.spec.tsx +++ b/src/packages/uploader/__tests__/uploader.spec.tsx @@ -286,3 +286,46 @@ test('preview component', () => { ) expect(clickFunc).toBeCalled() }) + +test('should handle paste upload', async () => { + // arrange + const onChange = vi.fn() + + const { container } = render( + + ) + + const file = new File(['image data'], 'pasted-image.png', { + type: 'image/png', + }) + + const pasteEvent = new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: new DataTransfer(), + }) + + pasteEvent.clipboardData?.items.add(file) + + // act + await import('@testing-library/react').then(({ act: testAct }) => + testAct(async () => { + container.firstChild?.dispatchEvent(pasteEvent) + }) + ) + + // assert + expect(onChange).toHaveBeenCalled() + + const lastCallArgs = onChange.mock.calls[onChange.mock.calls.length - 1][0] + + expect(lastCallArgs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'pasted-image.png', + type: 'image/png', + status: 'ready', + }), + ]) + ) +}) diff --git a/src/packages/uploader/demo.taro.tsx b/src/packages/uploader/demo.taro.tsx index df6dd432c9..6525d7b1b0 100644 --- a/src/packages/uploader/demo.taro.tsx +++ b/src/packages/uploader/demo.taro.tsx @@ -18,6 +18,7 @@ import Demo11 from './demos/taro/demo11' import Demo12 from './demos/taro/demo12' import Demo13 from './demos/taro/demo13' import Demo14 from './demos/taro/demo14' +import Demo15 from './demos/taro/demo15' const UploaderDemo = () => { const [translated] = useTranslate({ @@ -35,6 +36,7 @@ const UploaderDemo = () => { manualExecution: '选中文件后,通过按钮手动执行上传', disabled: '禁用状态', customDeleteIcon: '自定义删除icon', + enablePasteUpload: '启用粘贴上传', }, 'zh-TW': { basic: '基础用法', @@ -50,6 +52,7 @@ const UploaderDemo = () => { manualExecution: '選取檔後,通過按鈕手動執行上傳', disabled: '禁用狀態', customDeleteIcon: '自定義刪除icon', + enablePasteUpload: '啟用粘貼上傳', }, 'en-US': { basic: 'Basic usage', @@ -67,6 +70,7 @@ const UploaderDemo = () => { 'After selecting Chinese, manually perform the upload via the button', disabled: 'Disabled state', customDeleteIcon: 'Custom DeleteIcon', + enablePasteUpload: 'Enable paste upload', }, }) @@ -102,6 +106,8 @@ const UploaderDemo = () => {

{translated.customDeleteIcon}

+

{translated.enablePasteUpload}

+ ) diff --git a/src/packages/uploader/demo.tsx b/src/packages/uploader/demo.tsx index 2af3fe7b25..d051a14c8b 100644 --- a/src/packages/uploader/demo.tsx +++ b/src/packages/uploader/demo.tsx @@ -15,6 +15,7 @@ import Demo11 from './demos/h5/demo11' import Demo12 from './demos/h5/demo12' import Demo13 from './demos/h5/demo13' import Demo14 from './demos/h5/demo14' +import Demo15 from './demos/h5/demo15' const UploaderDemo = () => { const [translated] = useTranslate({ @@ -32,6 +33,7 @@ const UploaderDemo = () => { manualExecution: '选中文件后,通过按钮手动执行上传', disabled: '禁用状态', customDeleteIcon: '自定义删除icon', + enablePasteUpload: '启用粘贴上传', }, 'zh-TW': { basic: '基础用法', @@ -47,6 +49,7 @@ const UploaderDemo = () => { manualExecution: '選取檔後,通過按鈕手動執行上傳', disabled: '禁用狀態', customDeleteIcon: '自定義刪除icon', + enablePasteUpload: '啟用粘貼上傳', }, 'en-US': { basic: 'Basic usage', @@ -63,6 +66,7 @@ const UploaderDemo = () => { 'After selecting Chinese, manually perform the upload via the button', disabled: 'Disabled state', customDeleteIcon: 'Custom DeleteIcon', + enablePasteUpload: 'Enable paste upload', }, }) @@ -97,6 +101,8 @@ const UploaderDemo = () => {

{translated.customDeleteIcon}

+

{translated.enablePasteUpload}

+ ) diff --git a/src/packages/uploader/demos/h5/demo15.tsx b/src/packages/uploader/demos/h5/demo15.tsx new file mode 100644 index 0000000000..5a0c6ae6ed --- /dev/null +++ b/src/packages/uploader/demos/h5/demo15.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { Uploader } from '@nutui/nutui-react' + +const Demo15 = () => { + return ( + <> + + + ) +} +export default Demo15 diff --git a/src/packages/uploader/demos/taro/demo15.tsx b/src/packages/uploader/demos/taro/demo15.tsx new file mode 100644 index 0000000000..cd66b8b943 --- /dev/null +++ b/src/packages/uploader/demos/taro/demo15.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { Uploader } from '@nutui/nutui-react-taro' + +const Demo15 = () => { + return ( + <> + + + ) +} +export default Demo15 diff --git a/src/packages/uploader/doc.md b/src/packages/uploader/doc.md index 0926615069..3434128ca3 100644 --- a/src/packages/uploader/doc.md +++ b/src/packages/uploader/doc.md @@ -137,6 +137,16 @@ app.post('/upload', upload.single('file'), (req, res) => { ::: +### 浏览器中粘贴图片上传 + +在浏览器中可以通过 Ctrl+V(Mac 上是 Cmd+V) 或右键粘贴图片进行上传。 + +:::demo + + + +::: + ## Uploader ### Props @@ -193,6 +203,7 @@ app.post('/upload', upload.single('file'), (req, res) => { | url | 文件路径 | `-` | | type | 文件类型 | `image/jpeg` | | formData | 上传所需的data | `new FormData()` | +| enablePasteUpload | 是否支持粘贴上传,仅在浏览器端支持,在其他设备端,即使开启也不生效。 | `false` | ### Methods diff --git a/src/packages/uploader/uploader.taro.tsx b/src/packages/uploader/uploader.taro.tsx index 9d8413a864..79ebd4fd46 100644 --- a/src/packages/uploader/uploader.taro.tsx +++ b/src/packages/uploader/uploader.taro.tsx @@ -5,6 +5,7 @@ import React, { PropsWithChildren, useRef, useEffect, + useCallback, } from 'react' import classNames from 'classnames' import Taro, { @@ -119,6 +120,7 @@ export interface UploaderProps extends BasicComponent { beforeXhrUpload?: (xhr: XMLHttpRequest, options: any) => void beforeDelete?: (file: FileItem, files: FileItem[]) => boolean onFileItemClick?: (file: FileItem, index: number) => void + enablePasteUpload?: boolean } const defaultProps = { @@ -152,6 +154,7 @@ const defaultProps = { beforeDelete: (file: FileItem, files: FileItem[]) => { return true }, + enablePasteUpload: false, } as UploaderProps const InternalUploader: ForwardRefRenderFunction< @@ -202,6 +205,7 @@ const InternalUploader: ForwardRefRenderFunction< beforeUpload, beforeXhrUpload, beforeDelete, + enablePasteUpload, ...restProps } = { ...defaultProps, ...props } const [fileList, setFileList] = usePropsValue({ @@ -418,7 +422,7 @@ const InternalUploader: ForwardRefRenderFunction< for (const [key, value] of Object.entries(data)) { formData.append(key, value as any) } - formData.append(name, file.originalFileObj as Blob) + formData.append(name, (file.originalFileObj as Blob) ?? file) fileItem.name = file.originalFileObj?.name fileItem.type = file.originalFileObj?.type fileItem.formData = formData @@ -428,8 +432,22 @@ const InternalUploader: ForwardRefRenderFunction< if (preview) { fileItem.url = fileType === 'video' ? file.thumbTempFilePath : filepath } - executeUpload(fileItem, index) - results.push(fileItem) + if (preview && file.type?.includes('image')) { + const reader = new FileReader() + reader.onload = (event) => { + fileItem.url = event.target?.result as string + fileItem.path = event.target?.result as string + fileItem.name = (file as unknown as Blob).name + executeUpload(fileItem, index) + results.push(fileItem) + setFileList([...fileList, ...results]) + } + + reader.readAsDataURL(file.originalFileObj ?? (file as unknown as Blob)) + } else { + executeUpload(fileItem, index) + results.push(fileItem) + } }) setFileList([...fileList, ...results]) } @@ -506,6 +524,52 @@ const InternalUploader: ForwardRefRenderFunction< onFileItemClick?.(file, index) } + const handlePaste = useCallback( + (event: ClipboardEvent) => { + if (!enablePasteUpload || disabled) return + + const clipboardData = event.clipboardData ?? (window as any).clipboardData + const items = clipboardData?.items ?? [] + const files: TFileType[] = [] + + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile() + if (file) { + files.push({ + originalFileObj: file, + size: file.size, + path: '', + tempFilePath: '', + type: file.type, + fileType: file.type, + }) + } + } + } + + if (files.length) { + readFile(files) + } + }, + [enablePasteUpload, disabled, beforeUpload, filterFiles, readFile, onChange] + ) + + useEffect(() => { + fileListRef.current = fileList + + if (enablePasteUpload) { + document.addEventListener('paste', handlePaste) + } + + return () => { + if (enablePasteUpload) { + document.removeEventListener('paste', handlePaste) + } + } + }, [fileList, enablePasteUpload, handlePaste]) + return (
{(children || previewType === 'list') && ( diff --git a/src/packages/uploader/uploader.tsx b/src/packages/uploader/uploader.tsx index 87630d7ec7..2c7258542d 100644 --- a/src/packages/uploader/uploader.tsx +++ b/src/packages/uploader/uploader.tsx @@ -5,6 +5,7 @@ import React, { PropsWithChildren, useRef, useEffect, + useCallback, } from 'react' import classNames from 'classnames' import { Photograph, Failure } from '@nutui/icons-react' @@ -70,6 +71,7 @@ export interface UploaderProps extends BasicComponent { beforeXhrUpload?: (xhr: XMLHttpRequest, options: any) => void beforeDelete?: (file: FileItem, files: FileItem[]) => boolean onFileItemClick?: (file: FileItem, index: number) => void + enablePasteUpload?: boolean } const defaultProps = { @@ -99,6 +101,7 @@ const defaultProps = { beforeDelete: (file: FileItem, files: FileItem[]) => { return true }, + enablePasteUpload: false, } as UploaderProps const InternalUploader: ForwardRefRenderFunction< @@ -148,6 +151,7 @@ const InternalUploader: ForwardRefRenderFunction< beforeUpload, beforeXhrUpload, beforeDelete, + enablePasteUpload, ...restProps } = { ...defaultProps, ...props } const [fileList, setFileList] = usePropsValue({ @@ -307,7 +311,7 @@ const InternalUploader: ForwardRefRenderFunction< const reader = new FileReader() reader.onload = (event: ProgressEvent) => { fileItem.url = (event.target as FileReader).result as string - // setFileList([...fileList, fileItem]) + setFileList([...fileList, fileItem]) results.push(fileItem) } reader.readAsDataURL(file) @@ -383,6 +387,85 @@ const InternalUploader: ForwardRefRenderFunction< onFileItemClick?.(file, index) } + const handlePaste = useCallback( + (event: ClipboardEvent) => { + if (!enablePasteUpload || disabled) return + + const clipboardData = event.clipboardData + if (!clipboardData) return + + const files: File[] = [] + + if (clipboardData?.items && clipboardData.items.length) { + for (let i = 0; i < clipboardData.items.length; i++) { + const item = clipboardData.items[i] + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile() + if (file) { + files.push(file) + } + } + } + } else if (clipboardData?.files && clipboardData.files.length) { + for (let i = 0; i < clipboardData.files.length; i++) { + const file = clipboardData.files[i] + if (file.type.startsWith('image/')) { + files.push(file) + } + } + } + + if (files.length) { + if (beforeUpload) { + beforeUpload(files).then((f: Array | boolean) => { + if (typeof f === 'boolean') return + + const _files = filterFiles(new Array().slice.call(f)) + if (_files.length) { + readFile(_files) + onChange?.([ + ...fileList, + ...files.map((file) => ({ + name: file.name, + type: file.type, + status: 'ready', + })), + ]) + } + }) + } else { + const _files = filterFiles(new Array().slice.call(files)) + if (_files.length) { + readFile(_files) + onChange?.([ + ...fileList, + ..._files.map((file) => ({ + name: file.name, + type: file.type, + status: 'ready', + })), + ]) + } + } + } + }, + [enablePasteUpload, disabled, beforeUpload, filterFiles, readFile, onChange] + ) + + useEffect(() => { + fileListRef.current = fileList + + if (enablePasteUpload) { + document.addEventListener('paste', handlePaste) + } + + return () => { + if (enablePasteUpload) { + document.removeEventListener('paste', handlePaste) + } + } + }, [fileList, enablePasteUpload, handlePaste]) + return (
{(children || previewType === 'list') && (