From 8cb7f148716d7374ddf59ce18d19be95232b7258 Mon Sep 17 00:00:00 2001 From: Jeff Tian Date: Fri, 4 Apr 2025 11:51:31 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E4=B8=AD=E7=B2=98=E8=B4=B4=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=9B=B8=E5=BA=94=E7=9A=84=E6=BC=94=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../uploader/__tests__/uploader.spec.tsx | 43 ++++++++++ src/packages/uploader/demo.tsx | 6 ++ src/packages/uploader/demos/h5/demo15.tsx | 11 +++ src/packages/uploader/doc.md | 9 ++ src/packages/uploader/uploader.taro.tsx | 20 ++++- src/packages/uploader/uploader.tsx | 85 ++++++++++++++++++- 6 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 src/packages/uploader/demos/h5/demo15.tsx 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.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..f3cb19a774 --- /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/doc.md b/src/packages/uploader/doc.md index 0926615069..5b41f1d677 100644 --- a/src/packages/uploader/doc.md +++ b/src/packages/uploader/doc.md @@ -137,6 +137,14 @@ app.post('/upload', upload.single('file'), (req, res) => { ::: +### 浏览器中粘贴图片上传 + +:::demo + + + +::: + ## Uploader ### Props @@ -193,6 +201,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..e5c0ce0d78 100644 --- a/src/packages/uploader/uploader.taro.tsx +++ b/src/packages/uploader/uploader.taro.tsx @@ -418,7 +418,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 +428,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 as unknown as Blob) + } else { + executeUpload(fileItem, index) + results.push(fileItem) + } }) setFileList([...fileList, ...results]) } diff --git a/src/packages/uploader/uploader.tsx b/src/packages/uploader/uploader.tsx index 87630d7ec7..95bc6c5ca5 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') && ( From d1403e0d3d39ed80da7dc9c80858c8b5747de804 Mon Sep 17 00:00:00 2001 From: Jeff Tian Date: Fri, 4 Apr 2025 12:37:55 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=E6=8C=89=E7=85=A7=20code=20rev?= =?UTF-8?q?iew=20=E5=BB=BA=E8=AE=AE=E5=81=9A=E4=BA=9B=E5=B0=8F=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/uploader/demos/h5/demo15.tsx | 2 +- src/packages/uploader/doc.md | 2 ++ src/packages/uploader/uploader.tsx | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/packages/uploader/demos/h5/demo15.tsx b/src/packages/uploader/demos/h5/demo15.tsx index f3cb19a774..5a0c6ae6ed 100644 --- a/src/packages/uploader/demos/h5/demo15.tsx +++ b/src/packages/uploader/demos/h5/demo15.tsx @@ -4,7 +4,7 @@ import { Uploader } from '@nutui/nutui-react' const Demo15 = () => { return ( <> - + ) } diff --git a/src/packages/uploader/doc.md b/src/packages/uploader/doc.md index 5b41f1d677..c4bf23664e 100644 --- a/src/packages/uploader/doc.md +++ b/src/packages/uploader/doc.md @@ -139,6 +139,8 @@ app.post('/upload', upload.single('file'), (req, res) => { ### 浏览器中粘贴图片上传 +在浏览器中可以通过 Ctrl+V(Mac 上是 Cmd+V) 或右键粘贴图片进行上传。 + :::demo diff --git a/src/packages/uploader/uploader.tsx b/src/packages/uploader/uploader.tsx index 95bc6c5ca5..2c7258542d 100644 --- a/src/packages/uploader/uploader.tsx +++ b/src/packages/uploader/uploader.tsx @@ -396,7 +396,7 @@ const InternalUploader: ForwardRefRenderFunction< const files: File[] = [] - if (clipboardData.items && clipboardData.items.length) { + 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/')) { @@ -406,7 +406,7 @@ const InternalUploader: ForwardRefRenderFunction< } } } - } else if (clipboardData.files && clipboardData.files.length) { + } 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/')) { From d0c3d74de6658b9e76bab485f388ba646d5d44b5 Mon Sep 17 00:00:00 2001 From: Jeff Tian Date: Sat, 19 Apr 2025 17:18:09 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(#3151):=20=E4=B8=BA=20taro=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=9A=84=20uploader=20=E5=A2=9E=E5=8A=A0=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E4=B8=8A=E4=BC=A0=E7=9A=84=E6=BC=94=E7=A4=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/uploader/demo.taro.tsx | 6 ++ src/packages/uploader/demos/taro/demo15.tsx | 15 +++++ src/packages/uploader/uploader.taro.tsx | 62 ++++++++++++++++++++- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/packages/uploader/demos/taro/demo15.tsx 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/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/uploader.taro.tsx b/src/packages/uploader/uploader.taro.tsx index e5c0ce0d78..3ecb1baa62 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({ @@ -439,7 +443,7 @@ const InternalUploader: ForwardRefRenderFunction< setFileList([...fileList, ...results]) } - reader.readAsDataURL(file as unknown as Blob) + reader.readAsDataURL(file.originalFileObj ?? (file as unknown as Blob)) } else { executeUpload(fileItem, index) results.push(fileItem) @@ -520,6 +524,62 @@ const InternalUploader: ForwardRefRenderFunction< onFileItemClick?.(file, index) } + const handlePaste = useCallback( + (event: ClipboardEvent) => { + if (!enablePasteUpload || disabled) return + + const clipboardData = event.clipboardData + if (!clipboardData) return + + const files: TFileType[] = [] + + 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({ + originalFileObj: file, + size: file.size, + path: '', + tempFilePath: '', + type: file.type, + fileType: file.type, + }) + } + } + } + } 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) { + 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') && ( From 63655293512a0e24751e4134cfcd7e04b23e5126 Mon Sep 17 00:00:00 2001 From: Jeff Tian Date: Sat, 19 Apr 2025 22:20:26 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(#3151):=20=E5=A2=9E=E5=8A=A0=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E4=B8=8A=E4=BC=A0=E8=AF=B4=E6=98=8E=E4=BB=85=E5=9C=A8?= =?UTF-8?q?=20h5=20=E7=AB=AF=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/uploader/doc.md | 2 +- src/packages/uploader/uploader.taro.tsx | 40 ++++++++++--------------- 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/src/packages/uploader/doc.md b/src/packages/uploader/doc.md index c4bf23664e..3434128ca3 100644 --- a/src/packages/uploader/doc.md +++ b/src/packages/uploader/doc.md @@ -203,7 +203,7 @@ app.post('/upload', upload.single('file'), (req, res) => { | url | 文件路径 | `-` | | type | 文件类型 | `image/jpeg` | | formData | 上传所需的data | `new FormData()` | -| enablePasteUpload | 是否支持粘贴上传 | `false` | +| enablePasteUpload | 是否支持粘贴上传,仅在浏览器端支持,在其他设备端,即使开启也不生效。 | `false` | ### Methods diff --git a/src/packages/uploader/uploader.taro.tsx b/src/packages/uploader/uploader.taro.tsx index 3ecb1baa62..79ebd4fd46 100644 --- a/src/packages/uploader/uploader.taro.tsx +++ b/src/packages/uploader/uploader.taro.tsx @@ -528,33 +528,23 @@ const InternalUploader: ForwardRefRenderFunction< (event: ClipboardEvent) => { if (!enablePasteUpload || disabled) return - const clipboardData = event.clipboardData - if (!clipboardData) return - + const clipboardData = event.clipboardData ?? (window as any).clipboardData + const items = clipboardData?.items ?? [] const files: TFileType[] = [] - 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({ - originalFileObj: file, - size: file.size, - path: '', - tempFilePath: '', - type: file.type, - fileType: file.type, - }) - } - } - } - } 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) + 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, + }) } } }