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: new component Attachments #168

Merged
merged 30 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4832896
chore: init
zombieJ Oct 17, 2024
abb18c8
docs: drap upload
zombieJ Oct 17, 2024
4416ac7
chore: support placeholder
zombieJ Oct 17, 2024
e031bd0
feat: placeholder drag
zombieJ Oct 17, 2024
507b11c
docs: update doc
zombieJ Oct 18, 2024
9613407
feat: file list
zombieJ Oct 18, 2024
34d7f82
chore: filelist attachment
zombieJ Oct 18, 2024
d863cd9
chore: img style
zombieJ Oct 21, 2024
0abb823
feat: img scale
zombieJ Oct 21, 2024
c441055
chore: type of it
zombieJ Oct 21, 2024
f36a319
chore: droparea support container
zombieJ Oct 22, 2024
5743d3a
chore: good for drop
zombieJ Oct 22, 2024
ef11a34
docs: update demo
zombieJ Oct 23, 2024
2612293
Merge remote-tracking branch 'origin/main' into attachment
zombieJ Oct 24, 2024
f88654c
docs: demo
zombieJ Oct 24, 2024
91209e6
test: basic test case
zombieJ Oct 24, 2024
aa259dd
test: coverage
zombieJ Oct 24, 2024
dee7f77
docs: semantic block
zombieJ Oct 24, 2024
07eb40c
docs: update demo
zombieJ Oct 24, 2024
e8d79eb
chore: fix lint
zombieJ Oct 24, 2024
e37266a
test: update snapshot
zombieJ Oct 24, 2024
59708bc
test: update snapshot
zombieJ Oct 24, 2024
b918d7b
chore: update lock file
zombieJ Oct 24, 2024
ee15fca
test: update snapshot
zombieJ Oct 24, 2024
96bb07c
chore: merge main
zombieJ Oct 25, 2024
70222c8
docs: update demo
zombieJ Oct 25, 2024
df87bb9
chore: update lock file
zombieJ Oct 25, 2024
cc4a184
chore: adjust code
zombieJ Oct 25, 2024
9e51079
docs: update demo
zombieJ Oct 25, 2024
1bfe966
test: update snapshot
zombieJ Oct 25, 2024
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
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
Loading