Skip to content

feat: 增加浏览器中粘贴上传的功能,并增加相应的演示功能 #3151

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/packages/uploader/__tests__/uploader.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,46 @@ test('preview component', () => {
)
expect(clickFunc).toBeCalled()
})

test('should handle paste upload', async () => {
// arrange
const onChange = vi.fn()

const { container } = render(
<Uploader onChange={onChange} enablePasteUpload autoUpload={false} />
)

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',
}),
])
)
})
6 changes: 6 additions & 0 deletions src/packages/uploader/demo.taro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -35,6 +36,7 @@ const UploaderDemo = () => {
manualExecution: '选中文件后,通过按钮手动执行上传',
disabled: '禁用状态',
customDeleteIcon: '自定义删除icon',
enablePasteUpload: '启用粘贴上传',
},
'zh-TW': {
basic: '基础用法',
Expand All @@ -50,6 +52,7 @@ const UploaderDemo = () => {
manualExecution: '選取檔後,通過按鈕手動執行上傳',
disabled: '禁用狀態',
customDeleteIcon: '自定義刪除icon',
enablePasteUpload: '啟用粘貼上傳',
},
'en-US': {
basic: 'Basic usage',
Expand All @@ -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',
},
})

Expand Down Expand Up @@ -102,6 +106,8 @@ const UploaderDemo = () => {
<Demo13 />
<h2>{translated.customDeleteIcon}</h2>
<Demo14 />
<h2>{translated.enablePasteUpload}</h2>
<Demo15 />
</div>
</>
)
Expand Down
6 changes: 6 additions & 0 deletions src/packages/uploader/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -32,6 +33,7 @@ const UploaderDemo = () => {
manualExecution: '选中文件后,通过按钮手动执行上传',
disabled: '禁用状态',
customDeleteIcon: '自定义删除icon',
enablePasteUpload: '启用粘贴上传',
},
'zh-TW': {
basic: '基础用法',
Expand All @@ -47,6 +49,7 @@ const UploaderDemo = () => {
manualExecution: '選取檔後,通過按鈕手動執行上傳',
disabled: '禁用狀態',
customDeleteIcon: '自定義刪除icon',
enablePasteUpload: '啟用粘貼上傳',
},
'en-US': {
basic: 'Basic usage',
Expand All @@ -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',
},
})

Expand Down Expand Up @@ -97,6 +101,8 @@ const UploaderDemo = () => {
<Demo13 />
<h2>{translated.customDeleteIcon}</h2>
<Demo14 />
<h2>{translated.enablePasteUpload}</h2>
<Demo15 />
</div>
</>
)
Expand Down
11 changes: 11 additions & 0 deletions src/packages/uploader/demos/h5/demo15.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react'
import { Uploader } from '@nutui/nutui-react'

const Demo15 = () => {
return (
<>
<Uploader enablePasteUpload uploadLabel="点击上传或粘贴图片" />
</>
)
}
export default Demo15
15 changes: 15 additions & 0 deletions src/packages/uploader/demos/taro/demo15.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react'
import { Uploader } from '@nutui/nutui-react-taro'

const Demo15 = () => {
return (
<>
<Uploader
url="https://my-json-server.typicode.com/linrufeng/demo/posts"
enablePasteUpload
uploadLabel="点击上传或粘贴图片"
/>
</>
)
}
export default Demo15
11 changes: 11 additions & 0 deletions src/packages/uploader/doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ app.post('/upload', upload.single('file'), (req, res) => {

:::

### 浏览器中粘贴图片上传

在浏览器中可以通过 Ctrl+V(Mac 上是 Cmd+V) 或右键粘贴图片进行上传。

:::demo

<CodeBlock src='h5/demo15.tsx'></CodeBlock>

:::

## Uploader

### Props
Expand Down Expand Up @@ -193,6 +203,7 @@ app.post('/upload', upload.single('file'), (req, res) => {
| url | 文件路径 | `-` |
| type | 文件类型 | `image/jpeg` |
| formData | 上传所需的data | `new FormData()` |
| enablePasteUpload | 是否支持粘贴上传,仅在浏览器端支持,在其他设备端,即使开启也不生效。 | `false` |

### Methods

Expand Down
70 changes: 67 additions & 3 deletions src/packages/uploader/uploader.taro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React, {
PropsWithChildren,
useRef,
useEffect,
useCallback,
} from 'react'
import classNames from 'classnames'
import Taro, {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -152,6 +154,7 @@ const defaultProps = {
beforeDelete: (file: FileItem, files: FileItem[]) => {
return true
},
enablePasteUpload: false,
} as UploaderProps

const InternalUploader: ForwardRefRenderFunction<
Expand Down Expand Up @@ -202,6 +205,7 @@ const InternalUploader: ForwardRefRenderFunction<
beforeUpload,
beforeXhrUpload,
beforeDelete,
enablePasteUpload,
...restProps
} = { ...defaultProps, ...props }
const [fileList, setFileList] = usePropsValue({
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Comment on lines +435 to +450
Copy link

@coderabbitai coderabbitai bot Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

文件读取逻辑优化建议

图片文件读取逻辑进行了异步处理,但当前实现存在以下问题:

  1. 代码结构导致可能重复添加文件到fileList
  2. 条件分支下的状态更新逻辑不一致

建议修改为以下实现,确保状态更新的一致性:

      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)
      }
    })
+   // 确保只在所有文件处理完成后一次性更新fileList
    setFileList([...fileList, ...results])

Committable suggestion skipped: line range outside the PR's diff.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里 if 下面的 setFileList 不能删除,因为它是异步触发的。

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jeff-Tian 您是对的,这里的 setFileList 不能删除。由于 FileReader.onload 是异步回调函数,它会在文件读取完成后才执行。如果只在外部调用 setFileList,那么异步读取的图片文件可能还没有被添加到 results 数组中,导致状态更新不完整。

保留这个异步回调中的 setFileList 可以确保用户上传图片后立即看到预览效果,而不必等待所有文件处理完成。


✏️ Learnings added
Learnt from: Jeff-Tian
PR: jdf2e/nutui-react#3151
File: src/packages/uploader/uploader.taro.tsx:435-450
Timestamp: 2025-04-19T14:22:49.831Z
Learning: 在 Uploader 组件中处理图片文件时,FileReader.onload 回调内的 setFileList 调用不能删除,因为它是异步触发的,确保图片读取完成后及时更新状态显示预览。

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

})
setFileList([...fileList, ...results])
}
Expand Down Expand Up @@ -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 (
<div className={classes} {...restProps}>
{(children || previewType === 'list') && (
Expand Down
Loading