Skip to content
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

feat: add ImageSelector #55

Merged
merged 1 commit into from
Sep 10, 2023
Merged
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
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>
Loading