-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: new component Attachments (#168)
* chore: init * docs: drap upload * chore: support placeholder * feat: placeholder drag * docs: update doc * feat: file list * chore: filelist attachment * chore: img style * feat: img scale * chore: type of it * chore: droparea support container * chore: good for drop * docs: update demo * docs: demo * test: basic test case * test: coverage * docs: semantic block * docs: update demo * chore: fix lint * test: update snapshot * test: update snapshot * chore: update lock file * test: update snapshot * docs: update demo * chore: update lock file * chore: adjust code * docs: update demo * test: update snapshot
- Loading branch information
Showing
36 changed files
with
5,536 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import classnames from 'classnames'; | ||
import React from 'react'; | ||
import { createPortal } from 'react-dom'; | ||
import { AttachmentContext } from './context'; | ||
|
||
export interface DropUploaderProps { | ||
prefixCls: string; | ||
className: string; | ||
getDropContainer?: null | (() => HTMLElement | null | undefined); | ||
children?: React.ReactNode; | ||
} | ||
|
||
export default function DropArea(props: DropUploaderProps) { | ||
const { getDropContainer, className, prefixCls, children } = props; | ||
const { disabled } = React.useContext(AttachmentContext); | ||
|
||
const [container, setContainer] = React.useState<HTMLElement | null | undefined>(); | ||
const [showArea, setShowArea] = React.useState<boolean | null>(null); | ||
|
||
// ========================== Container =========================== | ||
React.useEffect(() => { | ||
const nextContainer = getDropContainer?.(); | ||
if (container !== nextContainer) { | ||
setContainer(nextContainer); | ||
} | ||
}, [getDropContainer]); | ||
|
||
// ============================= Drop ============================= | ||
React.useEffect(() => { | ||
// Add global drop event | ||
if (container) { | ||
const onDragEnter = () => { | ||
setShowArea(true); | ||
}; | ||
|
||
// Should prevent default to make drop event work | ||
const onDragOver = (e: DragEvent) => { | ||
e.preventDefault(); | ||
}; | ||
|
||
const onDragLeave = (e: DragEvent) => { | ||
if (!e.relatedTarget) { | ||
setShowArea(false); | ||
} | ||
}; | ||
const onDrop = (e: DragEvent) => { | ||
setShowArea(false); | ||
e.preventDefault(); | ||
}; | ||
|
||
document.addEventListener('dragenter', onDragEnter); | ||
document.addEventListener('dragover', onDragOver); | ||
document.addEventListener('dragleave', onDragLeave); | ||
document.addEventListener('drop', onDrop); | ||
return () => { | ||
document.removeEventListener('dragenter', onDragEnter); | ||
document.removeEventListener('dragover', onDragOver); | ||
document.removeEventListener('dragleave', onDragLeave); | ||
document.removeEventListener('drop', onDrop); | ||
}; | ||
} | ||
}, [!!container]); | ||
|
||
// =========================== Visible ============================ | ||
const showDropdown = getDropContainer && container && showArea && !disabled; | ||
|
||
// ============================ Render ============================ | ||
if (!showDropdown) { | ||
return null; | ||
} | ||
|
||
const areaCls = `${prefixCls}-drop-area`; | ||
|
||
return createPortal( | ||
<div | ||
className={classnames(areaCls, className, { | ||
[`${areaCls}-on-body`]: container.tagName === 'BODY', | ||
})} | ||
> | ||
{children} | ||
</div>, | ||
container, | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,226 @@ | ||
import { | ||
CloseCircleFilled, | ||
FileExcelFilled, | ||
FileImageFilled, | ||
FileMarkdownFilled, | ||
FilePdfFilled, | ||
FilePptFilled, | ||
FileTextFilled, | ||
FileWordFilled, | ||
FileZipFilled, | ||
} from '@ant-design/icons'; | ||
import classNames from 'classnames'; | ||
import React from 'react'; | ||
import type { Attachment } from '..'; | ||
import { AttachmentContext } from '../context'; | ||
import { previewImage } from '../util'; | ||
import Progress from './Progress'; | ||
|
||
export interface FileListCardProps { | ||
prefixCls: string; | ||
item: Attachment; | ||
onRemove: (item: Attachment) => void; | ||
className?: string; | ||
style?: React.CSSProperties; | ||
} | ||
|
||
const EMPTY = '\u00A0'; | ||
|
||
const DEFAULT_ICON_COLOR = '#8c8c8c'; | ||
|
||
const IMG_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg']; | ||
|
||
const PRESET_FILE_ICONS: { | ||
ext: string[]; | ||
color: string; | ||
icon: React.ReactElement; | ||
}[] = [ | ||
{ | ||
icon: <FileExcelFilled />, | ||
color: '#22b35e', | ||
ext: ['xlsx', 'xls'], | ||
}, | ||
{ | ||
icon: <FileImageFilled />, | ||
color: DEFAULT_ICON_COLOR, | ||
ext: IMG_EXTS, | ||
}, | ||
{ | ||
icon: <FileMarkdownFilled />, | ||
color: DEFAULT_ICON_COLOR, | ||
ext: ['md', 'mdx'], | ||
}, | ||
{ | ||
icon: <FilePdfFilled />, | ||
color: '#ff4d4f', | ||
ext: ['pdf'], | ||
}, | ||
{ | ||
icon: <FilePptFilled />, | ||
color: '#ff6e31', | ||
ext: ['ppt', 'pptx'], | ||
}, | ||
{ | ||
icon: <FileWordFilled />, | ||
color: '#1677ff', | ||
ext: ['doc', 'docx'], | ||
}, | ||
{ | ||
icon: <FileZipFilled />, | ||
color: '#fab714', | ||
ext: ['zip', 'rar', '7z', 'tar', 'gz'], | ||
}, | ||
]; | ||
|
||
function matchExt(suffix: string, ext: string[]) { | ||
return ext.some((e) => suffix.toLowerCase() === `.${e}`); | ||
} | ||
|
||
function getSize(size: number) { | ||
let retSize = size; | ||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; | ||
let unitIndex = 0; | ||
|
||
while (retSize >= 1024 && unitIndex < units.length - 1) { | ||
retSize /= 1024; | ||
unitIndex++; | ||
} | ||
|
||
return `${retSize.toFixed(0)} ${units[unitIndex]}`; | ||
} | ||
|
||
function FileListCard(props: FileListCardProps, ref: React.Ref<HTMLDivElement>) { | ||
const { prefixCls, item, onRemove, className, style } = props; | ||
const { disabled } = React.useContext(AttachmentContext); | ||
|
||
const { name, size, percent, status = 'done' } = item; | ||
const cardCls = `${prefixCls}-card`; | ||
|
||
// ============================== Name ============================== | ||
const [namePrefix, nameSuffix] = React.useMemo(() => { | ||
const nameStr = name || ''; | ||
const match = nameStr.match(/^(.*)\.[^.]+$/); | ||
return match ? [match[1], nameStr.slice(match[1].length)] : [nameStr, '']; | ||
}, [name]); | ||
|
||
const isImg = React.useMemo(() => matchExt(nameSuffix, IMG_EXTS), [nameSuffix]); | ||
|
||
// ============================== Desc ============================== | ||
const desc = React.useMemo(() => { | ||
if (status === 'uploading') { | ||
return `${percent || 0}%`; | ||
} | ||
|
||
if (status === 'error') { | ||
return item.response || EMPTY; | ||
} | ||
|
||
return size ? getSize(size) : EMPTY; | ||
}, [status, percent]); | ||
|
||
// ============================== Icon ============================== | ||
const [icon, iconColor] = React.useMemo(() => { | ||
for (const { ext, icon, color } of PRESET_FILE_ICONS) { | ||
if (matchExt(nameSuffix, ext)) { | ||
return [icon, color]; | ||
} | ||
} | ||
|
||
return [<FileTextFilled key="defaultIcon" />, DEFAULT_ICON_COLOR]; | ||
}, [nameSuffix]); | ||
|
||
// ========================== ImagePreview ========================== | ||
const [previewImg, setPreviewImg] = React.useState<string>(); | ||
|
||
React.useEffect(() => { | ||
if (item.originFileObj) { | ||
let synced = true; | ||
previewImage(item.originFileObj).then((url) => { | ||
if (synced) { | ||
setPreviewImg(url); | ||
} | ||
}); | ||
|
||
return () => { | ||
synced = false; | ||
}; | ||
} | ||
setPreviewImg(undefined); | ||
}, [item.originFileObj]); | ||
|
||
// ============================= Render ============================= | ||
let content: React.ReactNode = null; | ||
|
||
if (isImg) { | ||
// Preview Image style | ||
content = ( | ||
<> | ||
<img alt="preview" src={item.thumbUrl || item.url || previewImg} /> | ||
|
||
{status !== 'done' && ( | ||
<div className={`${cardCls}-img-mask`}> | ||
{status === 'uploading' && percent !== undefined && ( | ||
<Progress percent={percent} prefixCls={cardCls} /> | ||
)} | ||
{status === 'error' && ( | ||
<div className={`${cardCls}-desc`}> | ||
<div className={`${cardCls}-ellipsis-prefix`}>{desc}</div> | ||
</div> | ||
)} | ||
</div> | ||
)} | ||
</> | ||
); | ||
} else { | ||
// Preview Card style | ||
content = ( | ||
<> | ||
<div className={`${cardCls}-icon`} style={{ color: iconColor }}> | ||
{icon} | ||
</div> | ||
<div className={`${cardCls}-content`}> | ||
<div className={`${cardCls}-name`}> | ||
<div className={`${cardCls}-ellipsis-prefix`}>{namePrefix ?? EMPTY}</div> | ||
<div className={`${cardCls}-ellipsis-suffix`}>{nameSuffix}</div> | ||
</div> | ||
<div className={`${cardCls}-desc`}> | ||
<div className={`${cardCls}-ellipsis-prefix`}>{desc}</div> | ||
</div> | ||
</div> | ||
</> | ||
); | ||
} | ||
|
||
return ( | ||
<div | ||
className={classNames( | ||
cardCls, | ||
{ | ||
[`${cardCls}-status-${status}`]: status, | ||
[`${cardCls}-type-preview`]: isImg, | ||
[`${cardCls}-type-overview`]: !isImg, | ||
}, | ||
className, | ||
)} | ||
style={style} | ||
ref={ref} | ||
> | ||
{content} | ||
|
||
{/* Remove Icon */} | ||
{!disabled && ( | ||
<button | ||
type="button" | ||
className={`${cardCls}-remove`} | ||
onClick={() => { | ||
onRemove(item); | ||
}} | ||
> | ||
<CloseCircleFilled /> | ||
</button> | ||
)} | ||
</div> | ||
); | ||
} | ||
|
||
export default React.forwardRef(FileListCard); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { Progress as AntdProgress, theme } from 'antd'; | ||
import React from 'react'; | ||
|
||
export interface ProgressProps { | ||
prefixCls: string; | ||
percent: number; | ||
} | ||
|
||
export default function Progress(props: ProgressProps) { | ||
const { percent } = props; | ||
const { token } = theme.useToken(); | ||
|
||
return ( | ||
<AntdProgress | ||
type="circle" | ||
percent={percent} | ||
size={token.fontSizeHeading2 * 2} | ||
strokeColor="#FFF" | ||
trailColor="rgba(255, 255, 255, 0.3)" | ||
format={(ptg) => <span style={{ color: '#FFF' }}>{(ptg || 0).toFixed(0)}%</span>} | ||
/> | ||
); | ||
} |
Oops, something went wrong.