优化
This commit is contained in:
2
client/packages/design/.gitignore
vendored
Normal file
2
client/packages/design/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
es
|
||||
lib
|
||||
12
client/packages/design/.storybook/main.js
Normal file
12
client/packages/design/.storybook/main.js
Normal file
@@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
stories: [
|
||||
'../components/**/*.stories.mdx',
|
||||
'../components/**/*.stories.@(js|jsx|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: '@storybook/react',
|
||||
};
|
||||
11
client/packages/design/.storybook/preview.js
Normal file
11
client/packages/design/.storybook/preview.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'antd/dist/antd.css';
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
3
client/packages/design/README.md
Normal file
3
client/packages/design/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
Tailchat 的前端组件
|
||||
|
||||
能进这个包的原则是该组件是一个业务无关的组件(无环境依赖)
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { AutoFolder } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/AutoFolder',
|
||||
component: AutoFolder,
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof AutoFolder>;
|
||||
|
||||
const Template: ComponentStory<typeof AutoFolder> = (args) => (
|
||||
<AutoFolder {...args} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
maxHeight: 100,
|
||||
children: (
|
||||
<div style={{ border: '1px solid #999' }}>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
<div>foooooo</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
88
client/packages/design/components/AutoFolder/index.tsx
Normal file
88
client/packages/design/components/AutoFolder/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
interface AutoFolderProps extends PropsWithChildren {
|
||||
maxHeight: number;
|
||||
showFullText?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
export const AutoFolder: React.FC<AutoFolderProps> = React.memo((props) => {
|
||||
const { showFullText = 'More', backgroundColor = 'white' } = props;
|
||||
const [isShowFullBtn, setIsShowFullBtn] = useState(false); // 是否显示展示所有内容的按钮
|
||||
const [isShowFull, setIsShowFull] = useState(false); // 是否点击按钮展示所有
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new window.ResizeObserver((entries) => {
|
||||
if (entries[0]) {
|
||||
const { height } = entries[0].contentRect;
|
||||
|
||||
if (height > maxHeight) {
|
||||
setIsShowFull(false);
|
||||
setIsShowFullBtn(true);
|
||||
|
||||
observer.disconnect(); // 触发一次则解除连接
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(contentRef.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const maxHeight = useMemo(() => {
|
||||
if (isShowFull) {
|
||||
return 'none';
|
||||
} else {
|
||||
return props.maxHeight;
|
||||
}
|
||||
}, [isShowFull, props.maxHeight]);
|
||||
|
||||
const handleClickShowFullBtn = useMemoizedFn(() => {
|
||||
setIsShowFullBtn(false);
|
||||
setIsShowFull(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxHeight,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>{props.children}</div>
|
||||
|
||||
{isShowFullBtn && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
backgroundImage: `linear-gradient(rgba(0,0,0,0), ${backgroundColor})`,
|
||||
padding: '4px 0',
|
||||
}}
|
||||
onClick={handleClickShowFullBtn}
|
||||
>
|
||||
{showFullText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AutoFolder.displayName = 'AutoFolder';
|
||||
59
client/packages/design/components/Avatar/combined.css
Normal file
59
client/packages/design/components/Avatar/combined.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.td-combined-avatar {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.td-combined-avatar-2 > .line1 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: white;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.td-combined-avatar-3 > .line2 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
height: 1px;
|
||||
background-color: white;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.td-combined-avatar-3 > .line1 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: white;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.td-combined-avatar-4 > .line2 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: white;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.td-combined-avatar-4 > .line1 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: white;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.td-combined-avatar__item {
|
||||
position: absolute;
|
||||
border-radius: 0;
|
||||
}
|
||||
106
client/packages/design/components/Avatar/combined.tsx
Normal file
106
client/packages/design/components/Avatar/combined.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Avatar, AvatarProps } from '.';
|
||||
import React from 'react';
|
||||
import _take from 'lodash/take';
|
||||
import { px2rem } from './utils';
|
||||
import './combined.css';
|
||||
|
||||
interface CombinedAvatarProps {
|
||||
shape?: 'circle' | 'square';
|
||||
size?: number;
|
||||
items: Pick<AvatarProps, 'name' | 'src'>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装式头像
|
||||
*/
|
||||
export const CombinedAvatar: React.FC<CombinedAvatarProps> = React.memo(
|
||||
(props) => {
|
||||
const { size = 32, shape = 'circle' } = props;
|
||||
const items = _take(props.items, 4);
|
||||
|
||||
const length = items.length;
|
||||
const getCellStyle = (i: number): React.CSSProperties => {
|
||||
if (length === 1) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (length === 2) {
|
||||
if (i === 0) {
|
||||
return {
|
||||
width: '50%',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
};
|
||||
}
|
||||
if (i === 1) {
|
||||
return {
|
||||
width: '50%',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (length === 3) {
|
||||
if (i === 0) {
|
||||
return {
|
||||
width: '50%',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
};
|
||||
}
|
||||
if (i === 1) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '100% 0 0' };
|
||||
}
|
||||
if (i === 2) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '100% 100% 0' };
|
||||
}
|
||||
}
|
||||
|
||||
if (length === 4) {
|
||||
if (i === 0) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '0 0 0' };
|
||||
}
|
||||
if (i === 1) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '100% 0 0' };
|
||||
}
|
||||
if (i === 2) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '0 100% 0' };
|
||||
}
|
||||
if (i === 3) {
|
||||
return { transform: 'scale(50%)', transformOrigin: '100% 100% 0' };
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`td-combined-avatar td-combined-avatar-${length}`}
|
||||
style={{
|
||||
width: px2rem(size),
|
||||
height: px2rem(size),
|
||||
borderRadius: shape === 'circle' ? '50%' : 3,
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<Avatar
|
||||
key={i}
|
||||
className="td-combined-avatar__item"
|
||||
style={getCellStyle(i)}
|
||||
size={size}
|
||||
{...item}
|
||||
/>
|
||||
))}
|
||||
|
||||
{items.length >= 2 && <div className="line1" />}
|
||||
{items.length >= 3 && <div className="line2" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
CombinedAvatar.displayName = 'CombinedAvatar';
|
||||
120
client/packages/design/components/Avatar/index.stories.tsx
Normal file
120
client/packages/design/components/Avatar/index.stories.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { Avatar } from '.';
|
||||
import { CombinedAvatar } from './combined';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/Avatar',
|
||||
component: Avatar,
|
||||
argTypes: {
|
||||
name: {
|
||||
description: '显示名称,用于无图片下的展示',
|
||||
},
|
||||
isOnline: {
|
||||
description: '是否在线, 可不传',
|
||||
},
|
||||
size: {
|
||||
description: '图标大小',
|
||||
type: 'number',
|
||||
},
|
||||
src: {
|
||||
description: '头像图片地址',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof Avatar>;
|
||||
|
||||
const Template: ComponentStory<typeof Avatar> = (args) => <Avatar {...args} />;
|
||||
|
||||
export const normal = Template.bind({});
|
||||
normal.args = {
|
||||
name: 'Anonymous',
|
||||
};
|
||||
|
||||
export const withSize = Template.bind({});
|
||||
withSize.args = {
|
||||
name: 'Anonymous',
|
||||
size: 48,
|
||||
};
|
||||
|
||||
export const withOnline = Template.bind({});
|
||||
withOnline.args = {
|
||||
name: 'Anonymous',
|
||||
isOnline: true,
|
||||
};
|
||||
|
||||
export const withImage = Template.bind({});
|
||||
withImage.args = {
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
};
|
||||
|
||||
const CombinedTemplate: ComponentStory<typeof CombinedAvatar> = (args) => (
|
||||
<div>
|
||||
<CombinedAvatar {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const combined1 = CombinedTemplate.bind({});
|
||||
combined1.args = {
|
||||
size: 48,
|
||||
items: [
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const combined2 = CombinedTemplate.bind({});
|
||||
combined2.args = {
|
||||
size: 48,
|
||||
items: [
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const combined3 = CombinedTemplate.bind({});
|
||||
combined3.args = {
|
||||
size: 48,
|
||||
items: [
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const combined4 = CombinedTemplate.bind({});
|
||||
combined4.args = {
|
||||
size: 48,
|
||||
items: [
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
},
|
||||
{
|
||||
name: 'Anonymous',
|
||||
},
|
||||
],
|
||||
};
|
||||
106
client/packages/design/components/Avatar/index.tsx
Normal file
106
client/packages/design/components/Avatar/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { ForwardRefExoticComponent, useMemo } from 'react';
|
||||
import { Avatar as AntdAvatar, Badge } from 'antd';
|
||||
import _head from 'lodash/head';
|
||||
import _upperCase from 'lodash/upperCase';
|
||||
import _isNil from 'lodash/isNil';
|
||||
import _isEmpty from 'lodash/isEmpty';
|
||||
import _isNumber from 'lodash/isNumber';
|
||||
import _omit from 'lodash/omit';
|
||||
import type { AvatarProps as AntdAvatarProps } from 'antd/lib/avatar';
|
||||
import { getTextColorHex, px2rem } from './utils';
|
||||
import { isValidStr } from '../utils';
|
||||
import { imageUrlParser } from '../Image';
|
||||
|
||||
export { getTextColorHex };
|
||||
|
||||
export interface AvatarProps extends AntdAvatarProps {
|
||||
name?: string;
|
||||
isOnline?: boolean;
|
||||
}
|
||||
|
||||
const _Avatar: React.FC<AvatarProps> = React.memo((_props) => {
|
||||
const { isOnline, ...props } = _props;
|
||||
const src = isValidStr(props.src) ? imageUrlParser(props.src) : undefined;
|
||||
|
||||
const name = useMemo(() => _upperCase(_head(props.name)), [props.name]);
|
||||
|
||||
const color = useMemo(
|
||||
() =>
|
||||
// 如果src为空 且 icon为空 则给个固定颜色
|
||||
_isEmpty(src) && _isNil(props.icon)
|
||||
? getTextColorHex(props.name)
|
||||
: undefined,
|
||||
[src, props.icon, props.name]
|
||||
);
|
||||
|
||||
const style = useMemo(() => {
|
||||
const _style: React.CSSProperties = {
|
||||
userSelect: 'none',
|
||||
...props.style,
|
||||
backgroundColor: color,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
if (_isNumber(props.size)) {
|
||||
// 为了支持rem统一管理宽度,将size转换为样式宽度(size类型上不支持rem单位)
|
||||
if (!_style.width) {
|
||||
_style.width = px2rem(props.size);
|
||||
}
|
||||
if (!_style.height) {
|
||||
_style.height = px2rem(props.size);
|
||||
}
|
||||
|
||||
if (typeof _style.fontSize === 'undefined') {
|
||||
// 如果props.size是数字且没有指定文字大小
|
||||
// 则自动增加fontSize大小
|
||||
_style.fontSize = px2rem(props.size * 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
return _style;
|
||||
}, [props.style, props.size, color]);
|
||||
|
||||
const inner = (
|
||||
<AntdAvatar
|
||||
draggable={false}
|
||||
{..._omit(props, ['size'])}
|
||||
src={src}
|
||||
style={style}
|
||||
>
|
||||
{name}
|
||||
</AntdAvatar>
|
||||
);
|
||||
|
||||
if (typeof isOnline === 'boolean') {
|
||||
const style = {
|
||||
bottom: 0,
|
||||
top: 'auto',
|
||||
};
|
||||
|
||||
if (isOnline === true) {
|
||||
return (
|
||||
<Badge dot={true} color="green" style={style}>
|
||||
{inner}
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Badge dot={true} color="#999" style={style}>
|
||||
{inner}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return inner;
|
||||
});
|
||||
_Avatar.displayName = 'Avatar';
|
||||
|
||||
type CompoundedComponent = ForwardRefExoticComponent<AvatarProps> & {
|
||||
Group: typeof AntdAvatar.Group;
|
||||
};
|
||||
|
||||
export const Avatar: CompoundedComponent = _Avatar as any;
|
||||
Avatar.Group = AntdAvatar.Group;
|
||||
41
client/packages/design/components/Avatar/utils.ts
Normal file
41
client/packages/design/components/Avatar/utils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import _isString from 'lodash/isString';
|
||||
import str2int from 'str2int';
|
||||
|
||||
const colors = [
|
||||
'#333333',
|
||||
'#2c3e50',
|
||||
'#8e44ad',
|
||||
'#2980b9',
|
||||
'#27ae60',
|
||||
'#16a085',
|
||||
'#f39c12',
|
||||
'#d35400',
|
||||
'#c0392b',
|
||||
'#3498db',
|
||||
'#9b59b6',
|
||||
'#2ecc71',
|
||||
'#1abc9c',
|
||||
'#f1c40f',
|
||||
'#e74c3c',
|
||||
'#e67e22',
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据文本内容返回一个内置色卡的颜色
|
||||
* @param text 文本
|
||||
*/
|
||||
export function getTextColorHex(text: unknown): string {
|
||||
if (!text || !_isString(text)) {
|
||||
return '#ffffff'; // 如果获取不到文本,则返回白色
|
||||
}
|
||||
|
||||
const id = str2int(text);
|
||||
return colors[id % colors.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将像素转换为rem单位
|
||||
*/
|
||||
export function px2rem(size: number): string {
|
||||
return size * (1 / 16) + 'rem';
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { AvatarWithPreview } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/AvatarWithPreview',
|
||||
component: AvatarWithPreview,
|
||||
argTypes: {
|
||||
name: {
|
||||
description: '显示名称,用于无图片下的展示',
|
||||
},
|
||||
isOnline: {
|
||||
description: '是否在线, 可不传',
|
||||
},
|
||||
size: {
|
||||
description: '图标大小',
|
||||
type: 'number',
|
||||
},
|
||||
src: {
|
||||
description: '头像图片地址',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof AvatarWithPreview>;
|
||||
|
||||
const Template: ComponentStory<typeof AvatarWithPreview> = (args) => (
|
||||
<AvatarWithPreview {...args} />
|
||||
);
|
||||
|
||||
export const NoImage = Template.bind({});
|
||||
NoImage.args = {
|
||||
name: 'Anonymous',
|
||||
};
|
||||
|
||||
export const withImage = Template.bind({});
|
||||
withImage.args = {
|
||||
name: 'Anonymous',
|
||||
src: 'http://dummyimage.com/50x50',
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar, AvatarProps } from '../Avatar';
|
||||
import { Image, imageUrlParser } from '../Image';
|
||||
import { isValidStr } from '../utils';
|
||||
|
||||
export const AvatarWithPreview: React.FC<AvatarProps> = React.memo((props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const hasImage = isValidStr(props.src);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
cursor: hasImage ? 'pointer' : 'auto',
|
||||
}}
|
||||
onClick={() => setVisible(!visible)}
|
||||
>
|
||||
<Avatar {...props} />
|
||||
</div>
|
||||
|
||||
{hasImage && (
|
||||
<div
|
||||
style={{
|
||||
display: 'none',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
preview={{
|
||||
visible,
|
||||
src: imageUrlParser(String(props.src)),
|
||||
onVisibleChange: (value) => {
|
||||
setVisible(value);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
AvatarWithPreview.displayName = 'AvatarWithPreview';
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { CopyableText } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/CopyableText',
|
||||
component: CopyableText,
|
||||
argTypes: {
|
||||
config: {
|
||||
description: '见: https://ant.design/components/typography-cn#copyable',
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof CopyableText>;
|
||||
|
||||
const Template: ComponentStory<typeof CopyableText> = (args) => (
|
||||
<div>
|
||||
可复制文本: <CopyableText {...args} />
|
||||
<- 点击此处复制
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
children: 'Foo',
|
||||
};
|
||||
|
||||
export const WithConfig = Template.bind({});
|
||||
WithConfig.args = {
|
||||
children: 'Foo',
|
||||
config: {
|
||||
text: 'Bar',
|
||||
},
|
||||
};
|
||||
25
client/packages/design/components/CopyableText/index.tsx
Normal file
25
client/packages/design/components/CopyableText/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import type { BlockProps } from 'antd/lib/typography/Base';
|
||||
|
||||
interface CopyableTextProps extends React.PropsWithChildren {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
config?: BlockProps['copyable'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复制的文本
|
||||
*/
|
||||
export const CopyableText: React.FC<CopyableTextProps> = React.memo((props) => {
|
||||
return (
|
||||
<Typography.Text
|
||||
className={props.className}
|
||||
style={props.style}
|
||||
copyable={props.config ?? true}
|
||||
>
|
||||
{props.children}
|
||||
</Typography.Text>
|
||||
);
|
||||
});
|
||||
CopyableText.displayName = 'CopyableText';
|
||||
19
client/packages/design/components/DelayTip/index.stories.tsx
Normal file
19
client/packages/design/components/DelayTip/index.stories.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { DelayTip } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Tailchat/DelayTip',
|
||||
component: DelayTip,
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof DelayTip>;
|
||||
|
||||
const Template: ComponentStory<typeof DelayTip> = (args) => (
|
||||
<DelayTip {...args}>鼠标移动到这里等待1s</DelayTip>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
title: 'Hello World',
|
||||
};
|
||||
14
client/packages/design/components/DelayTip/index.tsx
Normal file
14
client/packages/design/components/DelayTip/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Tooltip, TooltipProps } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 延时提示
|
||||
*/
|
||||
export const DelayTip: React.FC<TooltipProps> = React.memo((props) => {
|
||||
return (
|
||||
<Tooltip mouseEnterDelay={1} {...props}>
|
||||
{props.children}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
DelayTip.displayName = 'DelayTip';
|
||||
@@ -0,0 +1,8 @@
|
||||
.highLight {
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
border-radius: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
7
client/packages/design/components/Highlight/index.tsx
Normal file
7
client/packages/design/components/Highlight/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import styles from './index.module.less';
|
||||
|
||||
export const Highlight: React.FC<PropsWithChildren> = React.memo((props) => {
|
||||
return <span className={styles.highLight}>{props.children}</span>;
|
||||
});
|
||||
Highlight.displayName = 'Highlight';
|
||||
31
client/packages/design/components/Icon/index.tsx
Normal file
31
client/packages/design/components/Icon/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Icon as Iconify,
|
||||
IconProps,
|
||||
addIcon,
|
||||
addCollection,
|
||||
} from '@iconify/react';
|
||||
|
||||
const placeHolderStyle = { width: '1em', height: '1em' };
|
||||
|
||||
const InternalIcon: React.FC<Omit<IconProps, 'ref'>> = React.memo((props) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Iconify {...props} onLoad={() => setLoaded(true)} />
|
||||
{!loaded && <span style={placeHolderStyle} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
InternalIcon.displayName = 'Icon';
|
||||
|
||||
type CompoundedComponent = React.FC<Omit<IconProps, 'ref'>> & {
|
||||
addIcon: typeof addIcon;
|
||||
addCollection: typeof addCollection;
|
||||
};
|
||||
|
||||
export const Icon = InternalIcon as CompoundedComponent;
|
||||
|
||||
Icon.addIcon = addIcon;
|
||||
Icon.addCollection = addCollection;
|
||||
37
client/packages/design/components/Image/index.stories.tsx
Normal file
37
client/packages/design/components/Image/index.stories.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { Image } from '.';
|
||||
|
||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
export default {
|
||||
title: 'Tailchat/Image',
|
||||
component: Image,
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof Image>;
|
||||
|
||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
||||
const Template: ComponentStory<typeof Image> = (args) => (
|
||||
<>
|
||||
<Image
|
||||
src={'https://source.unsplash.com/collection/94734566/1920x1080'}
|
||||
{...args}
|
||||
/>
|
||||
<div>一个简单的图片, 自带Fallback机制</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Default.args = {
|
||||
preview: true,
|
||||
width: 640,
|
||||
height: 360,
|
||||
};
|
||||
|
||||
export const Fallback = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Fallback.args = {
|
||||
src: '',
|
||||
};
|
||||
24
client/packages/design/components/Image/index.tsx
Normal file
24
client/packages/design/components/Image/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Image as AntdImage, ImageProps as AntdImageProps } from 'antd';
|
||||
|
||||
export let imageUrlParser = (url: string) => url;
|
||||
|
||||
const fallback =
|
||||
'data:image/png;base64,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';
|
||||
|
||||
export const Image: React.FC<AntdImageProps> = React.memo((props) => {
|
||||
return (
|
||||
<AntdImage
|
||||
fallback={fallback}
|
||||
preview={false}
|
||||
loading="lazy"
|
||||
{...props}
|
||||
src={props.src ? imageUrlParser(props.src) : undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Image.displayName = 'Image';
|
||||
|
||||
export function setImageUrlParser(parser: (url: string) => string): void {
|
||||
imageUrlParser = parser;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Meta title="Tailchat/Introduction" />
|
||||
|
||||
# Tailchat Design
|
||||
|
||||
Tailchat Design 是 msgbyte 专为 Tailchat 打造的一套设计样式,基于 `antd` 之上做了一层封装
|
||||
51
client/packages/design/components/RichEditor/bbcode.ts
Normal file
51
client/packages/design/components/RichEditor/bbcode.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { isArray, ObjectMark, RemirrorJSON } from 'remirror';
|
||||
|
||||
/**
|
||||
* 转换成BBCode
|
||||
*/
|
||||
export function transformToBBCode(json: RemirrorJSON): string {
|
||||
if (json.type === 'doc') {
|
||||
return (json.content ?? []).map(transformToBBCode).join('\n');
|
||||
}
|
||||
|
||||
if (json.type === 'paragraph') {
|
||||
return (json.content ?? []).map(transformToBBCode).join('');
|
||||
}
|
||||
|
||||
if (json.type === 'text') {
|
||||
let text = json.text ?? '';
|
||||
|
||||
if (isArray(json.marks)) {
|
||||
(json.marks ?? []).forEach((mark) => {
|
||||
if (typeof mark === 'string') {
|
||||
mark = { type: mark };
|
||||
}
|
||||
text = applyMarks(mark, text);
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 为text增加mark包裹
|
||||
*/
|
||||
function applyMarks(mark: ObjectMark, text: string): string {
|
||||
if (mark.type === 'bold') {
|
||||
return `[b]${text}[/b]`;
|
||||
}
|
||||
if (mark.type === 'underline') {
|
||||
return `[u]${text}[/u]`;
|
||||
}
|
||||
if (mark.type === 'italic') {
|
||||
return `[i]${text}[/i]`;
|
||||
}
|
||||
if (mark.type === 'code') {
|
||||
return `[code]${text}[/code]`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
12
client/packages/design/components/RichEditor/editor.css
Normal file
12
client/packages/design/components/RichEditor/editor.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.remirror-editor-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tailchat-rich-editor {
|
||||
height: 100%;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.tailchat-rich-editor p {
|
||||
margin: 0;
|
||||
}
|
||||
44
client/packages/design/components/RichEditor/editor.tsx
Normal file
44
client/packages/design/components/RichEditor/editor.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Remirror,
|
||||
useRemirror,
|
||||
OnChangeJSON,
|
||||
EditorComponent,
|
||||
} from '@remirror/react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import type { RemirrorJSON } from 'remirror';
|
||||
import { Toolbar } from './toolbar';
|
||||
import { extensions } from './extensions';
|
||||
import { transformToBBCode } from './bbcode';
|
||||
import './editor.css';
|
||||
|
||||
interface RichEditorProps extends React.PropsWithChildren {
|
||||
initContent: string;
|
||||
onChange: (bbcode: string) => void;
|
||||
}
|
||||
export const RichEditor: React.FC<RichEditorProps> = React.memo((props) => {
|
||||
const { manager, state } = useRemirror({
|
||||
extensions,
|
||||
content: props.initContent,
|
||||
stringHandler: 'html',
|
||||
selection: 'end',
|
||||
});
|
||||
|
||||
const handleChange = useMemoizedFn((json: RemirrorJSON) => {
|
||||
props.onChange(transformToBBCode(json));
|
||||
});
|
||||
|
||||
return (
|
||||
<Remirror
|
||||
classNames={['tailchat-rich-editor']}
|
||||
manager={manager}
|
||||
initialContent={state}
|
||||
>
|
||||
<Toolbar />
|
||||
<EditorComponent />
|
||||
<OnChangeJSON onChange={handleChange} />
|
||||
{props.children}
|
||||
</Remirror>
|
||||
);
|
||||
});
|
||||
RichEditor.displayName = 'RichEditor';
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
BoldExtension,
|
||||
CodeExtension,
|
||||
ItalicExtension,
|
||||
UnderlineExtension,
|
||||
} from 'remirror/extensions';
|
||||
|
||||
/**
|
||||
* 富文本编辑器使用的拓展
|
||||
*/
|
||||
export const extensions = () => [
|
||||
new BoldExtension(),
|
||||
new ItalicExtension(),
|
||||
new UnderlineExtension(),
|
||||
new CodeExtension(),
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { RichEditor } from './editor';
|
||||
|
||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
export default {
|
||||
title: 'Tailchat/RichEditor',
|
||||
component: RichEditor,
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof RichEditor>;
|
||||
|
||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
||||
const Template: ComponentStory<typeof RichEditor> = (args) => (
|
||||
<div style={{ height: 1000, width: '100%' }}>
|
||||
<RichEditor {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Default.args = {
|
||||
initContent: '<p>Hi <strong>Friend</strong></p>',
|
||||
};
|
||||
8
client/packages/design/components/RichEditor/index.tsx
Normal file
8
client/packages/design/components/RichEditor/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
/**
|
||||
* 富文本编辑器
|
||||
*/
|
||||
|
||||
export const RichEditor = React.lazy(() =>
|
||||
import('./editor').then((module) => ({ default: module.RichEditor }))
|
||||
);
|
||||
26
client/packages/design/components/RichEditor/toolbar.tsx
Normal file
26
client/packages/design/components/RichEditor/toolbar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FloatingToolbar,
|
||||
CommandButtonGroup,
|
||||
ToggleBoldButton,
|
||||
ToggleItalicButton,
|
||||
ToggleUnderlineButton,
|
||||
ToggleCodeButton,
|
||||
} from '@remirror/react';
|
||||
|
||||
/**
|
||||
* 菜单
|
||||
*/
|
||||
export const Toolbar: React.FC = React.memo(() => {
|
||||
return (
|
||||
<FloatingToolbar>
|
||||
<CommandButtonGroup>
|
||||
<ToggleBoldButton />
|
||||
<ToggleItalicButton />
|
||||
<ToggleUnderlineButton />
|
||||
<ToggleCodeButton />
|
||||
</CommandButtonGroup>
|
||||
</FloatingToolbar>
|
||||
);
|
||||
});
|
||||
Toolbar.displayName = 'Toolbar';
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { SensitiveText } from '.';
|
||||
|
||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
export default {
|
||||
title: 'Tailchat/SensitiveText',
|
||||
component: SensitiveText,
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof SensitiveText>;
|
||||
|
||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
||||
const Template: ComponentStory<typeof SensitiveText> = (args) => (
|
||||
<SensitiveText {...args} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Default.args = {
|
||||
text: 'fooooo',
|
||||
};
|
||||
40
client/packages/design/components/SensitiveText/index.tsx
Normal file
40
client/packages/design/components/SensitiveText/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
interface SensitiveTextProps {
|
||||
className?: string;
|
||||
text: string;
|
||||
}
|
||||
export const SensitiveText: React.FC<SensitiveTextProps> = React.memo(
|
||||
(props) => {
|
||||
const { className, text } = props;
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{show ? text : getMaskedText(text)}
|
||||
|
||||
<Icon
|
||||
style={{ cursor: 'pointer', marginLeft: 4 }}
|
||||
icon={show ? 'mdi:eye-off-outline' : 'mdi:eye-outline'}
|
||||
onClick={() => setShow((before) => !before)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SensitiveText.displayName = 'SensitiveText';
|
||||
|
||||
function getMaskedText(text: string) {
|
||||
const len = text.length;
|
||||
if (len > 2) {
|
||||
return `${text[0]}****${text[len - 1]}`;
|
||||
} else if (len === 2) {
|
||||
return `${text[0]}*`;
|
||||
} else {
|
||||
return '**';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
WIP: Warning
|
||||
|
||||
可变高度的虚拟列表
|
||||
|
||||
专为聊天场景打造的
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
type Size = { height: number; width: number };
|
||||
|
||||
interface ResizeWatcherProps extends React.PropsWithChildren {
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
onResize?: (size: Size) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当容器大小发生变化时
|
||||
* 触发回调
|
||||
*/
|
||||
export const ResizeWatcher: React.FC<ResizeWatcherProps> = React.memo(
|
||||
(props) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleResize = useMemoizedFn((size: Size) => {
|
||||
if (props.onResize) {
|
||||
props.onResize(size);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const { target, contentRect } = entry;
|
||||
if (!target.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 contentRect 计算大小以确保不会出现使用clientHeight立即向浏览器请求dom大小导致的性能问题
|
||||
handleResize({
|
||||
width: Math.round(contentRect.width),
|
||||
height: Math.round(contentRect.height),
|
||||
});
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(rootRef.current);
|
||||
|
||||
return () => {
|
||||
if (resizeObserver && rootRef.current) {
|
||||
resizeObserver.unobserve(rootRef.current);
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={props.wrapperStyle} ref={rootRef}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ResizeWatcher.displayName = 'ResizeWatcher';
|
||||
190
client/packages/design/components/VirtualChatList/Scroller.tsx
Normal file
190
client/packages/design/components/VirtualChatList/Scroller.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ResizeWatcher } from './ResizeWatcher';
|
||||
import { useMemoizedFn, useDebounceFn, usePrevious } from 'ahooks';
|
||||
import type { IsForward, Vector } from './types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ScrollerRef {
|
||||
scrollTo: (position: Vector) => void;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
type ScrollerProps = PropsWithChildren<{
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
innerStyle?: React.CSSProperties;
|
||||
isLock?: boolean;
|
||||
scrollingClassName?: string;
|
||||
scrollBehavior?: ScrollBehavior;
|
||||
onScroll?: (
|
||||
position: Vector,
|
||||
detail: {
|
||||
forward: IsForward;
|
||||
isUserScrolling: boolean;
|
||||
isMouseDown: boolean;
|
||||
}
|
||||
) => void;
|
||||
onScrollEnd?: (position: Vector) => void;
|
||||
onContainerResize?: (info: {
|
||||
containerSize: Vector;
|
||||
position: Vector;
|
||||
}) => void;
|
||||
}>;
|
||||
|
||||
const DEFAULT_POS = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* 滚动状态管理组件
|
||||
*/
|
||||
export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
|
||||
(props, ref) => {
|
||||
const { scrollBehavior = 'auto' } = props;
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const style = useMemo(() => {
|
||||
if (props.isLock ?? false) {
|
||||
return { ...props.style, overflow: 'hidden' };
|
||||
}
|
||||
|
||||
return { ...props.style, overflow: 'auto' };
|
||||
}, [props.isLock]);
|
||||
|
||||
const getPosition = useMemoizedFn(() => {
|
||||
if (!wrapperRef.current) {
|
||||
return DEFAULT_POS;
|
||||
}
|
||||
return {
|
||||
x: wrapperRef.current.scrollLeft,
|
||||
y: wrapperRef.current.scrollTop,
|
||||
};
|
||||
});
|
||||
|
||||
const getContainerSize = useMemoizedFn(() => {
|
||||
if (!wrapperRef.current) {
|
||||
return DEFAULT_POS;
|
||||
}
|
||||
return {
|
||||
x: wrapperRef.current.clientWidth,
|
||||
y: wrapperRef.current.clientHeight,
|
||||
};
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollTo: (position) => {
|
||||
wrapperRef.current?.scrollTo({
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
behavior: scrollBehavior,
|
||||
});
|
||||
},
|
||||
scrollToBottom: () => {
|
||||
wrapperRef.current?.scrollTo({
|
||||
left: getPosition().x,
|
||||
top: wrapperRef.current.scrollHeight - getContainerSize().y,
|
||||
behavior: scrollBehavior,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const [isScroll, setIsScroll] = useState(false);
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const { run: setIsScrollLazy } = useDebounceFn(
|
||||
(val) => {
|
||||
setIsScroll(val);
|
||||
},
|
||||
{
|
||||
leading: false,
|
||||
trailing: true,
|
||||
wait: 300,
|
||||
}
|
||||
);
|
||||
|
||||
const { run: handleEndScrollLazy } = useDebounceFn(
|
||||
() => {
|
||||
setIsScroll(false);
|
||||
if (props.onScrollEnd) {
|
||||
props.onScrollEnd(getPosition());
|
||||
}
|
||||
},
|
||||
{
|
||||
leading: false,
|
||||
trailing: true,
|
||||
wait: 300,
|
||||
}
|
||||
);
|
||||
|
||||
const handleWheel = useMemoizedFn(() => {
|
||||
setIsScroll(true);
|
||||
setIsScrollLazy(false);
|
||||
});
|
||||
|
||||
const handleMouseDown = useMemoizedFn(() => {
|
||||
setIsMouseDown(true);
|
||||
});
|
||||
|
||||
const handleMouseUp = useMemoizedFn(() => {
|
||||
setIsMouseDown(false);
|
||||
});
|
||||
|
||||
const prevPosition = usePrevious(getPosition()) ?? DEFAULT_POS;
|
||||
const handleScroll = useMemoizedFn(() => {
|
||||
const isUserScrolling = isScroll || isMouseDown;
|
||||
const currentPosition = getPosition();
|
||||
const forward = {
|
||||
x: currentPosition.x > prevPosition.x,
|
||||
y: currentPosition.y > prevPosition.y,
|
||||
};
|
||||
setIsScroll(true);
|
||||
handleEndScrollLazy();
|
||||
if (props.onScroll) {
|
||||
props.onScroll(currentPosition, {
|
||||
forward,
|
||||
isUserScrolling,
|
||||
isMouseDown: isMouseDown,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleResize = useMemoizedFn(() => {
|
||||
if (props.onContainerResize) {
|
||||
props.onContainerResize({
|
||||
containerSize: getContainerSize(),
|
||||
position: getPosition(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ResizeWatcher wrapperStyle={{ height: '100%' }} onResize={handleResize}>
|
||||
<div
|
||||
key="scroller"
|
||||
className={clsx(props.className, 'scroller', {
|
||||
[props.scrollingClassName ?? 'scrolling']: isScroll,
|
||||
})}
|
||||
style={style}
|
||||
ref={wrapperRef}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
className="scroller-inner"
|
||||
key="scroller-inner"
|
||||
style={props.innerStyle}
|
||||
ref={innerRef}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</ResizeWatcher>
|
||||
);
|
||||
}
|
||||
);
|
||||
Scroller.displayName = 'Scroller';
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { VirtualChatList } from '.';
|
||||
|
||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
export default {
|
||||
title: 'Tailchat/VirtualChatList',
|
||||
component: VirtualChatList,
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof VirtualChatList>;
|
||||
|
||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
||||
const Template: ComponentStory<typeof VirtualChatList> = (args) => (
|
||||
<VirtualChatList {...args} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Default.args = {
|
||||
text: 'fooooo',
|
||||
};
|
||||
82
client/packages/design/components/VirtualChatList/index.tsx
Normal file
82
client/packages/design/components/VirtualChatList/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { ResizeWatcher } from './ResizeWatcher';
|
||||
import { Scroller, ScrollerRef } from './Scroller';
|
||||
import { useUpdate } from 'ahooks';
|
||||
|
||||
interface VirtualChatListProps<ItemType> {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
innerStyle?: React.CSSProperties;
|
||||
getItemKey?: (item: ItemType) => string;
|
||||
items: ItemType[];
|
||||
itemContent: (item: ItemType, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
const defaultContainerStyle: React.CSSProperties = {
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const defaultInnerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const scrollerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const InternalVirtualChatList = <ItemType extends object>(
|
||||
props: VirtualChatListProps<ItemType>
|
||||
) => {
|
||||
const scrollerRef = useRef<ScrollerRef>(null);
|
||||
const itemHeightCache = useMemo(() => new Map<ItemType, number>(), []);
|
||||
const forceUpdate = useUpdate();
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
...defaultContainerStyle,
|
||||
...props.style,
|
||||
}),
|
||||
[props.style]
|
||||
);
|
||||
const innerStyle = useMemo(
|
||||
() => ({
|
||||
...defaultInnerStyle,
|
||||
...props.innerStyle,
|
||||
}),
|
||||
[props.innerStyle]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 挂载后滚动到底部
|
||||
scrollerRef.current?.scrollToBottom();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="virtual-chat-list" style={style}>
|
||||
<Scroller ref={scrollerRef} style={scrollerStyle} innerStyle={innerStyle}>
|
||||
{props.items.map((item, i) => (
|
||||
<div
|
||||
key={props.getItemKey ? props.getItemKey(item) : i}
|
||||
className="virtual-chat-list__item"
|
||||
style={{ height: itemHeightCache.get(item) }}
|
||||
>
|
||||
<ResizeWatcher
|
||||
onResize={(size) => {
|
||||
itemHeightCache.set(item, size.height);
|
||||
forceUpdate();
|
||||
}}
|
||||
>
|
||||
{props.itemContent(item, i)}
|
||||
</ResizeWatcher>
|
||||
</div>
|
||||
))}
|
||||
</Scroller>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type VirtualChatListInterface = typeof InternalVirtualChatList & React.FC;
|
||||
|
||||
export const VirtualChatList: VirtualChatListInterface = React.memo(
|
||||
InternalVirtualChatList
|
||||
) as any;
|
||||
VirtualChatList.displayName = 'VirtualChatList';
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface Vector {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface IsForward {
|
||||
x: boolean;
|
||||
y: boolean;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
describe('getValidateStatus', () => {
|
||||
test('enter undefined', () => {
|
||||
const status = getValidateStatus(undefined);
|
||||
|
||||
expect(status).toBe('');
|
||||
});
|
||||
|
||||
test('enter empty string', () => {
|
||||
const status = getValidateStatus('');
|
||||
|
||||
expect(status).toBe('');
|
||||
});
|
||||
|
||||
test('enter string', () => {
|
||||
const status = getValidateStatus('any string');
|
||||
|
||||
expect(status).toBe('error');
|
||||
});
|
||||
});
|
||||
87
client/packages/design/components/WebMetaForm/index.tsx
Normal file
87
client/packages/design/components/WebMetaForm/index.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
FastifyForm,
|
||||
regField,
|
||||
FastifyFormContainerComponent,
|
||||
regFormContainer,
|
||||
} from 'react-fastify-form';
|
||||
import { Form, Button } from 'antd';
|
||||
|
||||
import { FastifyFormText } from './types/Text';
|
||||
import { FastifyFormTextArea } from './types/TextArea';
|
||||
import { FastifyFormPassword } from './types/Password';
|
||||
import { FastifyFormSelect } from './types/Select';
|
||||
import { FastifyFormCheckbox } from './types/Checkbox';
|
||||
import { FastifyFormCustom } from './types/Custom';
|
||||
|
||||
regField('text', FastifyFormText);
|
||||
regField('textarea', FastifyFormTextArea);
|
||||
regField('password', FastifyFormPassword);
|
||||
regField('select', FastifyFormSelect);
|
||||
regField('checkbox', FastifyFormCheckbox);
|
||||
regField('custom', FastifyFormCustom);
|
||||
|
||||
let webFastifyFormConfig = {
|
||||
submitLabel: 'Submit',
|
||||
};
|
||||
|
||||
export function setWebFastifyFormConfig(config: typeof webFastifyFormConfig) {
|
||||
webFastifyFormConfig = {
|
||||
...webFastifyFormConfig,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
const WebFastifyFormContainer: FastifyFormContainerComponent = React.memo(
|
||||
(props) => {
|
||||
const layout = props.layout;
|
||||
const suffixElement = props.extraProps?.suffixElement;
|
||||
|
||||
const submitButtonRender = useMemo(() => {
|
||||
return (
|
||||
<Form.Item
|
||||
wrapperCol={
|
||||
layout === 'vertical'
|
||||
? { xs: 24 }
|
||||
: { sm: 24, md: { span: 16, offset: 8 } }
|
||||
}
|
||||
>
|
||||
<Button
|
||||
loading={props.loading}
|
||||
type="primary"
|
||||
size="large"
|
||||
htmlType="button"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => props.handleSubmit()}
|
||||
disabled={props.canSubmit === false}
|
||||
>
|
||||
{props.submitLabel ?? webFastifyFormConfig.submitLabel}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
);
|
||||
}, [
|
||||
props.loading,
|
||||
props.handleSubmit,
|
||||
props.canSubmit,
|
||||
props.submitLabel,
|
||||
layout,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
layout={layout}
|
||||
labelCol={layout === 'vertical' ? { xs: 24 } : { sm: 24, md: 8 }}
|
||||
wrapperCol={layout === 'vertical' ? { xs: 24 } : { sm: 24, md: 16 }}
|
||||
>
|
||||
{props.children}
|
||||
{suffixElement}
|
||||
{submitButtonRender}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
);
|
||||
WebFastifyFormContainer.displayName = 'WebFastifyFormContainer';
|
||||
regFormContainer(WebFastifyFormContainer);
|
||||
|
||||
export const WebMetaForm = FastifyForm;
|
||||
(WebMetaForm as any).displayName = 'WebMetaForm';
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Form, Checkbox } from 'antd';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
export const FastifyFormCheckbox: FastifyFormFieldComponent = React.memo(
|
||||
(props) => {
|
||||
const { name, label, value, onChange, error } = props;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
validateStatus={getValidateStatus(error)}
|
||||
help={error}
|
||||
>
|
||||
<Checkbox
|
||||
name={name}
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
FastifyFormCheckbox.displayName = 'FastifyFormCheckbox';
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Form } from 'antd';
|
||||
import type {
|
||||
FastifyFormFieldComponent,
|
||||
FastifyFormFieldProps,
|
||||
} from 'react-fastify-form';
|
||||
import { CustomField } from 'react-fastify-form';
|
||||
|
||||
export const FastifyFormCustom: FastifyFormFieldComponent<{
|
||||
render: (props: FastifyFormFieldProps) => React.ReactNode;
|
||||
}> = React.memo((props) => {
|
||||
const { label } = props;
|
||||
|
||||
return (
|
||||
<Form.Item label={label}>
|
||||
<CustomField {...props} />
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
FastifyFormCustom.displayName = 'FastifyFormCustom';
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Input, Form } from 'antd';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
export const FastifyFormPassword: FastifyFormFieldComponent = React.memo(
|
||||
(props) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
maxLength,
|
||||
placeholder,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
validateStatus={getValidateStatus(error)}
|
||||
help={error}
|
||||
>
|
||||
<Input.Password
|
||||
name={name}
|
||||
type="password"
|
||||
size="large"
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
FastifyFormPassword.displayName = 'FastifyFormPassword';
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Select, Form } from 'antd';
|
||||
import _get from 'lodash/get';
|
||||
import _isNil from 'lodash/isNil';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
|
||||
const Option = Select.Option;
|
||||
|
||||
interface FastifyFormSelectOptionsItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const FastifyFormSelect: FastifyFormFieldComponent<{
|
||||
options: FastifyFormSelectOptionsItem[];
|
||||
}> = React.memo((props) => {
|
||||
const { name, label, value, onChange, onBlur, options } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (_isNil(value) || value === '') {
|
||||
// 如果没有值的话则自动设置默认值
|
||||
onChange(_get(options, [0, 'value']));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form.Item label={label}>
|
||||
<Select
|
||||
size="large"
|
||||
value={value}
|
||||
onChange={(value) => onChange(value)}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{options.map((option, i) => (
|
||||
<Option key={`${option.value}${i}`} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
FastifyFormSelect.displayName = 'FastifyFormSelect';
|
||||
38
client/packages/design/components/WebMetaForm/types/Text.tsx
Normal file
38
client/packages/design/components/WebMetaForm/types/Text.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Input, Form } from 'antd';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
export const FastifyFormText: FastifyFormFieldComponent = React.memo(
|
||||
(props) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
maxLength,
|
||||
placeholder,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
validateStatus={getValidateStatus(error)}
|
||||
help={error}
|
||||
>
|
||||
<Input
|
||||
name={name}
|
||||
size="large"
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
FastifyFormText.displayName = 'FastifyFormText';
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Input, Form } from 'antd';
|
||||
import type { FastifyFormFieldComponent } from 'react-fastify-form';
|
||||
import { getValidateStatus } from '../utils';
|
||||
|
||||
export const FastifyFormTextArea: FastifyFormFieldComponent = React.memo(
|
||||
(props) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
maxLength,
|
||||
placeholder,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
validateStatus={getValidateStatus(error)}
|
||||
help={error}
|
||||
>
|
||||
<Input.TextArea
|
||||
name={name}
|
||||
rows={4}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
FastifyFormTextArea.displayName = 'FastifyFormTextArea';
|
||||
10
client/packages/design/components/WebMetaForm/utils.ts
Normal file
10
client/packages/design/components/WebMetaForm/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 获取校验状态
|
||||
*/
|
||||
export function getValidateStatus(error: string | undefined): 'error' | '' {
|
||||
if (error === undefined || error === '') {
|
||||
return '';
|
||||
} else {
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
26
client/packages/design/components/index.ts
Normal file
26
client/packages/design/components/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export { AutoFolder } from './AutoFolder';
|
||||
export { Avatar, getTextColorHex } from './Avatar';
|
||||
export { AvatarWithPreview } from './AvatarWithPreview';
|
||||
export { CopyableText } from './CopyableText';
|
||||
export { CombinedAvatar } from './Avatar/combined';
|
||||
export { DelayTip } from './DelayTip';
|
||||
export { Highlight } from './Highlight';
|
||||
export { Icon } from './Icon';
|
||||
export { Image, setImageUrlParser } from './Image';
|
||||
export { SensitiveText } from './SensitiveText';
|
||||
export { VirtualChatList } from './VirtualChatList';
|
||||
|
||||
export {
|
||||
WebMetaForm,
|
||||
setWebFastifyFormConfig as setWebMetaFormConfig,
|
||||
} from './WebMetaForm';
|
||||
export {
|
||||
createFastifyFormSchema as createMetaFormSchema,
|
||||
fieldSchema as metaFormFieldSchema,
|
||||
useFastifyFormContext as useMetaFormContext,
|
||||
useFastifyFormContext,
|
||||
} from 'react-fastify-form';
|
||||
export type {
|
||||
FastifyFormFieldMeta as MetaFormFieldMeta,
|
||||
FastifyFormFieldProps,
|
||||
} from 'react-fastify-form';
|
||||
7
client/packages/design/components/utils.ts
Normal file
7
client/packages/design/components/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 是否一个可用的字符串
|
||||
* 定义为有长度的字符串
|
||||
*/
|
||||
export function isValidStr(str: unknown): str is string {
|
||||
return typeof str == 'string' && str !== '';
|
||||
}
|
||||
59
client/packages/design/package.json
Normal file
59
client/packages/design/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "tailchat-design",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Tailchat frontend UI library",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "pnpm storybook",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"build-storybook": "build-storybook"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/msgbyte/tailchat.git"
|
||||
},
|
||||
"keywords": [
|
||||
"design",
|
||||
"tailchat"
|
||||
],
|
||||
"author": "moonrailgun <moonrailgun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/msgbyte/tailchat/issues"
|
||||
},
|
||||
"homepage": "https://github.com/msgbyte/tailchat#readme",
|
||||
"dependencies": {
|
||||
"@iconify/react": "^3.2.1",
|
||||
"@remirror/pm": "^2.0.0",
|
||||
"@remirror/react": "^2.0.9",
|
||||
"ahooks": "^3.7.4",
|
||||
"antd": "^4.24.13",
|
||||
"clsx": "^1.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-fastify-form": "1.0.13",
|
||||
"remirror": "^2.0.9",
|
||||
"str2int": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.9",
|
||||
"@storybook/addon-actions": "^6.4.22",
|
||||
"@storybook/addon-essentials": "^6.4.22",
|
||||
"@storybook/addon-interactions": "^6.4.22",
|
||||
"@storybook/addon-links": "^6.4.22",
|
||||
"@storybook/react": "^6.4.22",
|
||||
"@storybook/testing-library": "^0.0.11",
|
||||
"@types/lodash": "^4.14.170",
|
||||
"@types/react": "18.0.20",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"babel-loader": "^8.2.5",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"typescript": "^4.5.2",
|
||||
"webpack": "^5.72.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
}
|
||||
}
|
||||
8
client/packages/design/tsconfig.json
Normal file
8
client/packages/design/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationDir": ".",
|
||||
},
|
||||
"include": ["components/**/*"]
|
||||
}
|
||||
1
client/packages/design/types/index.d.ts
vendored
Normal file
1
client/packages/design/types/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.module.less';
|
||||
Reference in New Issue
Block a user