Skip to content

Commit

Permalink
feat: new component Attachments (#168)
Browse files Browse the repository at this point in the history
* 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
zombieJ authored Oct 25, 2024
1 parent 6a03f28 commit 6bb917f
Show file tree
Hide file tree
Showing 36 changed files with 5,536 additions and 8 deletions.
Binary file modified bun.lockb
Binary file not shown.
84 changes: 84 additions & 0 deletions components/attachments/DropArea.tsx
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,
);
}
226 changes: 226 additions & 0 deletions components/attachments/FileList/FileListCard.tsx
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);
23 changes: 23 additions & 0 deletions components/attachments/FileList/Progress.tsx
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>}
/>
);
}
Loading

0 comments on commit 6bb917f

Please sign in to comment.