Skip to content

Commit

Permalink
Merge pull request #56 from dancing-team/develop
Browse files Browse the repository at this point in the history
Feat: ImageSelector
  • Loading branch information
yusixian authored Sep 10, 2023
2 parents cbcf7f8 + 7cf417e commit 52411c0
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .changeset/tidy-humans-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@dance-ui/ui': patch
'@dance-ui/example': patch
'@dance-ui/demo': patch
---

feat: 新增图片选择器组件 ImageSelector
135 changes: 135 additions & 0 deletions packages/components/src/ImageSelector/ImageSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { ChangeEvent, Ref, forwardRef, useCallback, useImperativeHandle, useRef } from 'react'
import { twMerge } from 'tailwind-merge'
import Icon, { IconType } from '../Icon'

export type ImageSelectorProps = {
name?: string
className?: string
itemClass?: string
defaultImages?: string[]
maxSize?: number
images: string[]
onChange: (images: string[]) => void
upload: (file: File) => Promise<string | null> // 上传函数,成功返回图片url,失败返回false
onError?: (file: File) => void
addButtonClass?: string
renderAddButton?: ({ triggerFileInput }: { triggerFileInput: () => void }) => JSX.Element
closeIconClass?: string
renderCloseIcon?: ({ handleRemoveImage, index }: { index: number; handleRemoveImage: (index: number) => void }) => JSX.Element
}
const MAX_IMAGE_SIZE = 3 * 1024 * 1024 // 3M in bytes
const ImageSelector = forwardRef(
(
{
name,
className,
itemClass,
defaultImages,
images,
onChange,
maxSize = MAX_IMAGE_SIZE,
upload,
onError,

addButtonClass,
renderAddButton,
closeIconClass,
renderCloseIcon,
}: ImageSelectorProps,
ref: Ref<HTMLInputElement>,
) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleImageChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const filesArray = Array.from(e.target.files).filter((file) => {
if (file.size > maxSize) {
onError?.(file)
return false
}
return true
})
try {
const res = await Promise.all(filesArray.map(upload))
const urls = res.filter((url) => {
if (url) return true
return false
})
onChange([...images, ...urls])
} catch (e) {
console.log(e)
}
}
},
[images, maxSize, onChange, onError, upload],
)

const handleRemoveImage = (index: number) => {
const updatedImages = [...images]
updatedImages.splice(index, 1)
onChange(updatedImages)
}
// 使用useImperativeHandle来同步内部ref和外部ref
useImperativeHandle(ref, () => fileInputRef.current)

const triggerFileInput = () => {
fileInputRef.current?.click()
}

return (
<div className={twMerge('flex flex-wrap items-center gap-3', className)}>
{defaultImages?.length
? defaultImages.map((url) => (
<div key={url} className={twMerge('relative h-32 w-32 rounded-lg bg-black/20 dark:bg-white/20', itemClass)}>
<img src={url} alt={url} className="h-full w-full rounded-lg object-cover" />
</div>
))
: null}
{images.map((url, index) => (
<div key={index} className={twMerge('relative h-32 w-32 rounded-lg bg-black/20 dark:bg-white/20', itemClass)}>
<img src={url} alt={url} className="h-full w-full rounded-lg object-cover" />
{renderCloseIcon ? (
renderCloseIcon({ handleRemoveImage, index })
) : (
<Icon
onClick={() => {
handleRemoveImage(index)
}}
type={IconType.CLOSE}
className={twMerge('absolute -right-3 -top-3 h-6 w-6 cursor-pointer fill-red-500', closeIconClass)}
/>
)}
</div>
))}
<input
type="file"
name={name}
ref={fileInputRef}
multiple
onChange={(e) => {
void handleImageChange(e)
}}
className="hidden"
/>
{renderAddButton ? (
renderAddButton({ triggerFileInput })
) : (
<div
className={twMerge(
'flex h-20 w-28 cursor-pointer items-center justify-center rounded-lg bg-black/10 text-xl font-bold dark:bg-white/20',
addButtonClass,
)}
onClick={triggerFileInput}>
+
</div>
)}
</div>
)
},
)
ImageSelector.displayName = 'ImageSelector'
ImageSelector.defaultProps = {
maxSize: MAX_IMAGE_SIZE,
}

export default ImageSelector
72 changes: 72 additions & 0 deletions packages/components/src/ImageSelector/demo/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, { useState } from 'react'
import { Space, ImageSelector } from '@dance-ui/ui'

export default () => {
const [selectedImages, setSelectedImages] = useState<string[]>([])

const upload = (file: File) => {
console.log(`uploadingImg`, file)
return 'https://fakeimg.pl/350x200/?text=MockUploadBackUrl'
}
const handleImagesSelected = (urls: string[]) => {
console.log(`handleImagesSelected`, urls)
setSelectedImages(urls)
}

const handleFileError = (file: File) => {
console.error(`File upload error: ${file.name}`)
}

const renderAddButton = ({ triggerFileInput }: { triggerFileInput: () => void }) => (
<div onClick={triggerFileInput} style={{ backgroundColor: 'lightblue', padding: '10px', borderRadius: '5px' }}>
Custom Add Button
</div>
)

const renderCloseIcon = ({ handleRemoveImage, index }: { index: number; handleRemoveImage: (index: number) => void }) => (
<div
onClick={() => handleRemoveImage(index)}
className="flex items-center justify-center"
style={{
position: 'absolute',
backgroundColor: 'red',
color: 'white',
top: '-12px',
right: '-12px',
borderRadius: '100%',
width: '24px',
height: '24px',
}}>
x
</div>
)
return (
<Space direction="vertical">
<Space direction="vertical">
<p>基础使用</p>
<ImageSelector upload={upload} images={selectedImages} onChange={handleImagesSelected} />
</Space>
<Space direction="vertical">
<p>default不可修改的主图</p>
<ImageSelector
upload={upload}
images={selectedImages}
onChange={handleImagesSelected}
defaultImages={['https://fakeimg.pl/350x200/?text=Hello']}
/>
</Space>
<Space direction="vertical">
<p>自定义添加按钮和关闭图标</p>
<ImageSelector
upload={upload}
images={selectedImages}
onChange={handleImagesSelected}
onError={handleFileError}
renderAddButton={renderAddButton}
renderCloseIcon={renderCloseIcon}
defaultImages={['https://fakeimg.pl/350x200/?text=Test1', 'https://fakeimg.pl/350x200/?text=Test2']}
/>
</Space>
</Space>
)
}
3 changes: 3 additions & 0 deletions packages/components/src/ImageSelector/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import ImageSelector from './ImageSelector'
export type { ImageSelectorProps } from './ImageSelector'
export default ImageSelector
2 changes: 2 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ export { default as Tabs } from './Tabs'
export { default as DatePicker } from './DatePicker'

export { default as RadioGroup } from './RadioGroup'

export { default as ImageSelector } from './ImageSelector'
57 changes: 57 additions & 0 deletions packages/example/docs/components/ImageSelector.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
sidebar_position: 6
---

import ComponentSource from '!!raw-loader!../../../components/src/ImageSelector/ImageSelector'
import { ImageSelector } from '@dance-ui/ui'

# ImageSelector 图片选择器

`ImageSelector` 是一个用于上传和显示图片的组件。它允许用户选择多张图片,并提供了一个简单的界面来查看和删除已上传的图片。

### API

#### ImageSelectorProps

| 属性 | 说明 | 类型 | 默认值 |
| --------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------- |
| name | input 文件输入的 name 属性 | string | - |
| className | 组件的类名 | string | - |
| itemClass | 图片项的类名 | string | - |
| defaultImages | 默认显示的图片数组 | string[] | - |
| maxSize | 允许上传的最大文件大小(以字节为单位) | number | 3 _ 1024 _ 1024 |
| images | 当前已上传的图片的 URL 数组 | string[] | - |
| onChange | 当图片数组发生变化时的回调函数,参数为当前的图片 URL 数组 | (images: string[]) => void | - |
| upload | 上传函数,成功返回图片 url,失败返回 null | `(file: File) => Promise<string \| null>` | - |
| onError | 文件上传错误时的回调函数 | (file: File) => void | - |
| addButtonClass | 添加按钮的类名 | string | - |
| renderAddButton | 自定义渲染添加按钮的函数 | ({ triggerFileInput }: { triggerFileInput: () => void }) => JSX.Element | - |
| closeIconClass | 关闭图标的类名 | string | - |
| renderCloseIcon | 自定义渲染关闭图标的函数 | ({ handleRemoveImage, index }: { index: number; handleRemoveImage: (index: number) => void }) => JSX.Element | - |

### 代码演示

#### 基本使用

在这个示例中,我们展示了 `ImageSelector` 组件的基本使用。用户可以通过点击 "+" 按钮来上传新的图片。上传的图片 URL 是一个模拟的 URL。

import DemoSrc from '!!raw-loader!../../../components/src/ImageSelector/demo'
import Demo from '../../../components/src/ImageSelector/demo'

<DemoBlock src={DemoSrc}>
<Demo />
</DemoBlock>

### 注意

1. `maxSize` 属性定义了可以上传的最大文件大小字节数,其默认值为 3MB (3*1024*1024)。
2. 上传函数 `upload` 是一个必须实现的函数,它接收一个文件对象作为参数,并返回一个 Promise。如果上传成功,Promise 应该解析为图片的 URL;如果上传失败,应该解析为 null。
3. `onError` 函数是一个可选的回调,它在文件上传错误时被调用,接收失败的文件对象作为参数。
4. `renderAddButton``renderCloseIcon` 允许你自定义添加按钮和关闭图标的渲染。
5. 删除图片时,目前的实现有一个小错误,它总是删除第一张图片而不是选定的图片。你应该使用 `updatedImages.splice(index, 1)` 而不是 `updatedImages.shift()` 来修复这个问题。

### 组件源码

<CodeBlock title="组件源码" language={'tsx'} showLineNumbers>
{ComponentSource}
</CodeBlock>

1 comment on commit 52411c0

@vercel
Copy link

@vercel vercel bot commented on 52411c0 Sep 10, 2023

Choose a reason for hiding this comment

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

Please sign in to comment.