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') && (