diff --git a/bun.lockb b/bun.lockb index c642ddae..52ed4e8a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/attachments/DropArea.tsx b/components/attachments/DropArea.tsx new file mode 100644 index 00000000..bde7bee6 --- /dev/null +++ b/components/attachments/DropArea.tsx @@ -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(); + const [showArea, setShowArea] = React.useState(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( +
+ {children} +
, + container, + ); +} diff --git a/components/attachments/FileList/FileListCard.tsx b/components/attachments/FileList/FileListCard.tsx new file mode 100644 index 00000000..c606e7da --- /dev/null +++ b/components/attachments/FileList/FileListCard.tsx @@ -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: , + color: '#22b35e', + ext: ['xlsx', 'xls'], + }, + { + icon: , + color: DEFAULT_ICON_COLOR, + ext: IMG_EXTS, + }, + { + icon: , + color: DEFAULT_ICON_COLOR, + ext: ['md', 'mdx'], + }, + { + icon: , + color: '#ff4d4f', + ext: ['pdf'], + }, + { + icon: , + color: '#ff6e31', + ext: ['ppt', 'pptx'], + }, + { + icon: , + color: '#1677ff', + ext: ['doc', 'docx'], + }, + { + icon: , + 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) { + 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 [, DEFAULT_ICON_COLOR]; + }, [nameSuffix]); + + // ========================== ImagePreview ========================== + const [previewImg, setPreviewImg] = React.useState(); + + 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 = ( + <> + preview + + {status !== 'done' && ( +
+ {status === 'uploading' && percent !== undefined && ( + + )} + {status === 'error' && ( +
+
{desc}
+
+ )} +
+ )} + + ); + } else { + // Preview Card style + content = ( + <> +
+ {icon} +
+
+
+
{namePrefix ?? EMPTY}
+
{nameSuffix}
+
+
+
{desc}
+
+
+ + ); + } + + return ( +
+ {content} + + {/* Remove Icon */} + {!disabled && ( + + )} +
+ ); +} + +export default React.forwardRef(FileListCard); diff --git a/components/attachments/FileList/Progress.tsx b/components/attachments/FileList/Progress.tsx new file mode 100644 index 00000000..e14fd641 --- /dev/null +++ b/components/attachments/FileList/Progress.tsx @@ -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 ( + {(ptg || 0).toFixed(0)}%} + /> + ); +} diff --git a/components/attachments/FileList/index.tsx b/components/attachments/FileList/index.tsx new file mode 100644 index 00000000..2c4ab45d --- /dev/null +++ b/components/attachments/FileList/index.tsx @@ -0,0 +1,169 @@ +import { LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'; +import { Button, type UploadProps } from 'antd'; +import classnames from 'classnames'; +import { CSSMotionList } from 'rc-motion'; +import React from 'react'; +import type { Attachment } from '..'; +import SilentUploader from '../SilentUploader'; +import { AttachmentContext } from '../context'; +import FileListCard from './FileListCard'; + +export interface FileListProps { + prefixCls: string; + items: Attachment[]; + onRemove: (item: Attachment) => void; + overflow?: 'scrollX' | 'scrollY' | 'wrap'; + upload: UploadProps; + + // Semantic + listClassName?: string; + listStyle?: React.CSSProperties; + itemClassName?: string; + itemStyle?: React.CSSProperties; +} + +export default function FileList(props: FileListProps) { + const { + prefixCls, + items, + onRemove, + overflow, + upload, + listClassName, + listStyle, + itemClassName, + itemStyle, + } = props; + + const listCls = `${prefixCls}-list`; + + const containerRef = React.useRef(null); + + const [firstMount, setFirstMount] = React.useState(false); + + const { disabled } = React.useContext(AttachmentContext); + + React.useEffect(() => { + setFirstMount(true); + return () => { + setFirstMount(false); + }; + }, []); + + // ================================= Scroll ================================= + const [pingStart, setPingStart] = React.useState(false); + const [pingEnd, setPingEnd] = React.useState(false); + + const checkPing = () => { + const containerEle = containerRef.current; + + if (!containerEle) { + return; + } + + if (overflow === 'scrollX') { + setPingStart(containerEle.scrollLeft !== 0); + setPingEnd( + containerEle.scrollWidth - containerEle.clientWidth !== Math.abs(containerEle.scrollLeft), + ); + } else if (overflow === 'scrollY') { + setPingStart(containerEle.scrollTop !== 0); + setPingEnd(containerEle.scrollHeight - containerEle.clientHeight !== containerEle.scrollTop); + } + }; + + React.useEffect(() => { + checkPing(); + }, [overflow]); + + const onScrollOffset = (offset: -1 | 1) => { + const containerEle = containerRef.current; + + if (containerEle) { + containerEle.scrollTo({ + left: containerEle.scrollLeft + offset * containerEle.clientWidth, + behavior: 'smooth', + }); + } + }; + + const onScrollLeft = () => { + onScrollOffset(-1); + }; + + const onScrollRight = () => { + onScrollOffset(1); + }; + + // ================================= Render ================================= + return ( +
+ ({ + key: item.uid, + item, + }))} + motionName={`${listCls}-card-motion`} + component={false} + motionAppear={firstMount} + motionLeave + motionEnter + > + {({ key, item, className: motionCls, style: motionStyle }) => { + return ( + + ); + }} + + {!disabled && ( + + + + )} + + {overflow === 'scrollX' && ( + <> +
+ ); +} diff --git a/components/attachments/PlaceholderUploader.tsx b/components/attachments/PlaceholderUploader.tsx new file mode 100644 index 00000000..b11f9c0b --- /dev/null +++ b/components/attachments/PlaceholderUploader.tsx @@ -0,0 +1,94 @@ +import { Flex, Typography, Upload, type UploadProps } from 'antd'; +import classNames from 'classnames'; +import React from 'react'; +import { AttachmentContext } from './context'; + +export interface PlaceholderConfig { + icon?: React.ReactNode; + title?: React.ReactNode; + description?: React.ReactNode; +} + +export type PlaceholderType = PlaceholderConfig | React.ReactElement; + +export interface PlaceholderProps { + prefixCls: string; + placeholder?: PlaceholderType; + upload?: UploadProps; + className?: string; + style?: React.CSSProperties; +} + +function Placeholder(props: PlaceholderProps, ref: React.Ref) { + const { prefixCls, placeholder = {}, upload, className, style } = props; + + const placeholderCls = `${prefixCls}-placeholder`; + + const placeholderConfig = (placeholder || {}) as PlaceholderConfig; + + const { disabled } = React.useContext(AttachmentContext); + + // ============================= Drag ============================= + const [dragIn, setDragIn] = React.useState(false); + + const onDragEnter = () => { + setDragIn(true); + }; + + const onDragLeave = (e: React.DragEvent) => { + // Leave the div should end + if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as HTMLElement)) { + setDragIn(false); + } + }; + + const onDrop = () => { + setDragIn(false); + }; + + // ============================ Render ============================ + const node = React.isValidElement(placeholder) ? ( + placeholder + ) : ( + + + {placeholderConfig.icon} + + + {placeholderConfig.title} + + + {placeholderConfig.description} + + + ); + + return ( +
+ + {node} + +
+ ); +} + +export default React.forwardRef(Placeholder); diff --git a/components/attachments/SilentUploader.tsx b/components/attachments/SilentUploader.tsx new file mode 100644 index 00000000..e12aabbe --- /dev/null +++ b/components/attachments/SilentUploader.tsx @@ -0,0 +1,22 @@ +import { Upload, type UploadProps } from 'antd'; +import React from 'react'; + +export interface SilentUploaderProps { + children: React.ReactElement; + upload: UploadProps; + rootClassName?: string; +} + +/** + * SilentUploader is only wrap children with antd Upload component. + */ +export default function SilentUploader(props: SilentUploaderProps) { + const { children, upload, rootClassName } = props; + + // ============================ Render ============================ + return ( + + {children} + + ); +} diff --git a/components/attachments/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/attachments/__tests__/__snapshots__/demo-extend.test.ts.snap new file mode 100644 index 00000000..048fc4f6 --- /dev/null +++ b/components/attachments/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -0,0 +1,1724 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders components/attachments/demo/basic.tsx extend context correctly 1`] = ` +
+
+
+
+
+ +
+ + + + +
+
+
+