This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

2
client/packages/design/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
es
lib

View 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',
};

View 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$/,
},
},
};

View File

@@ -0,0 +1,3 @@
Tailchat 的前端组件
能进这个包的原则是该组件是一个业务无关的组件(无环境依赖)

View File

@@ -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>
),
};

View 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';

View 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;
}

View 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';

View 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',
},
],
};

View 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;

View 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';
}

View File

@@ -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',
};

View File

@@ -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';

View File

@@ -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} />
&lt;-
</div>
);
export const Default = Template.bind({});
Default.args = {
children: 'Foo',
};
export const WithConfig = Template.bind({});
WithConfig.args = {
children: 'Foo',
config: {
text: 'Bar',
},
};

View 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';

View 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',
};

View 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';

View File

@@ -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;
}

View 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';

View 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;

View 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: '',
};

View 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;
}

View File

@@ -0,0 +1,5 @@
<Meta title="Tailchat/Introduction" />
# Tailchat Design
Tailchat Design 是 msgbyte 专为 Tailchat 打造的一套设计样式,基于 `antd` 之上做了一层封装

View 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;
}

View File

@@ -0,0 +1,12 @@
.remirror-editor-wrapper {
height: 100%;
}
.tailchat-rich-editor {
height: 100%;
outline: 0;
}
.tailchat-rich-editor p {
margin: 0;
}

View 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';

View File

@@ -0,0 +1,16 @@
import {
BoldExtension,
CodeExtension,
ItalicExtension,
UnderlineExtension,
} from 'remirror/extensions';
/**
* 富文本编辑器使用的拓展
*/
export const extensions = () => [
new BoldExtension(),
new ItalicExtension(),
new UnderlineExtension(),
new CodeExtension(),
];

View File

@@ -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>',
};

View File

@@ -0,0 +1,8 @@
import React from 'react';
/**
* 富文本编辑器
*/
export const RichEditor = React.lazy(() =>
import('./editor').then((module) => ({ default: module.RichEditor }))
);

View 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';

View File

@@ -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',
};

View 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 '**';
}
}

View File

@@ -0,0 +1,5 @@
WIP: Warning
可变高度的虚拟列表
专为聊天场景打造的

View File

@@ -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';

View 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';

View File

@@ -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',
};

View 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';

View File

@@ -0,0 +1,9 @@
export interface Vector {
x: number;
y: number;
}
export interface IsForward {
x: boolean;
y: boolean;
}

View File

@@ -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');
});
});

View 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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View 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';

View 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 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';

View File

@@ -0,0 +1,10 @@
/**
* 获取校验状态
*/
export function getValidateStatus(error: string | undefined): 'error' | '' {
if (error === undefined || error === '') {
return '';
} else {
return 'error';
}
}

View 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';

View File

@@ -0,0 +1,7 @@
/**
* 是否一个可用的字符串
* 定义为有长度的字符串
*/
export function isValidStr(str: unknown): str is string {
return typeof str == 'string' && str !== '';
}

View 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"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationDir": ".",
},
"include": ["components/**/*"]
}

View File

@@ -0,0 +1 @@
declare module '*.module.less';

View File

@@ -0,0 +1 @@
lib

View File

@@ -0,0 +1 @@
WIP

View File

@@ -0,0 +1,40 @@
{
"name": "tailchat-plugin-declaration-generator",
"private": true,
"version": "1.0.0",
"description": "",
"main": "lib/index.js",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"prepare": "tsc",
"test": "ts-node ./test/index.ts",
"test:parser": "ts-node ./test/parser.ts",
"test:parser:debug": "node -r ts-node/register --inspect-brk ./test/parser.ts"
},
"author": "",
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.20.5",
"@babel/parser": "^7.20.5",
"@babel/template": "^7.18.10",
"@babel/traverse": "^7.20.5",
"fs-extra": "^10.1.0",
"glob": "^7.2.3",
"lodash": "^4.17.21",
"mkdirp": "^1.0.4",
"ts-morph": "^16.0.0",
"typescript": "^4.9.4"
},
"devDependencies": {
"@babel/types": "^7.20.5",
"@types/babel__generator": "^7.6.4",
"@types/babel__template": "^7.4.1",
"@types/babel__traverse": "^7.18.3",
"@types/fs-extra": "^9.0.13",
"@types/lodash": "^4.14.191",
"@types/mkdirp": "^1.0.2",
"@types/node": "^18.11.16",
"ts-node": "^10.9.1"
}
}

View File

@@ -0,0 +1,90 @@
import { parse, ParserPlugin } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';
import template from '@babel/template';
import type { Comment } from '@babel/types';
import { program, isFunctionDeclaration } from '@babel/types';
import fs from 'fs-extra';
import _ from 'lodash';
export * from './tsgenerator';
export * from './parser';
const babelPlugins: ParserPlugin[] = ['jsx', 'typescript'];
const buildNamedExport = template('export function %%name%%(): any', {
plugins: babelPlugins,
});
interface Options {
entryPath: string;
// targetPath: string; // TODO
}
export async function generateFunctionDeclare(options: Options) {
const sourcecode = await fs.readFile(options.entryPath, 'utf8');
const exported = getSourceCodeExportedFunction(sourcecode);
const astList = exported.map((e) => {
return buildNamedExport({
name: e.name,
});
});
const code = generate(program(_.flatten(astList))).code;
return code;
}
interface ExportedItem {
name: string;
comments?: string;
}
function getSourceCodeExportedFunction(sourcecode: string): ExportedItem[] {
const ast = parse(sourcecode, {
sourceType: 'module',
plugins: babelPlugins,
});
const exported: ExportedItem[] = [];
traverse(ast, {
ExportNamedDeclaration({ node }) {
if (node.declaration) {
if (isFunctionDeclaration(node.declaration)) {
const name = node.declaration.id?.name;
if (typeof name === 'string') {
exported.push({
name,
comments: getCommentStr(node.leadingComments),
});
}
}
} else {
const names = node.specifiers.map((s) => {
const exported = s.exported;
if (exported.type === 'Identifier') {
return {
name: exported.name,
comments: getCommentStr(node.leadingComments),
};
} else {
return null;
}
});
exported.push(...names.filter((n): n is any => !!n));
}
},
});
return exported;
}
function getCommentStr(
comments: Comment[] | null | undefined
): string | undefined {
if (!comments) {
return undefined;
}
return comments.map((c) => c.value).join('\n');
}

View File

@@ -0,0 +1,112 @@
import {
Project,
ProjectOptions,
Symbol,
SyntaxKind,
ts,
Type,
} from 'ts-morph';
interface Options {
entryPath: string;
project?: ProjectOptions;
hardcodeExportType?: Record<string, string>;
}
export function parseDeclarationEntry(options: Options) {
const project = new Project(options.project);
const sourceFile = project.getSourceFileOrThrow(options.entryPath);
const hardcodeExportType = options.hardcodeExportType ?? {};
const exportDefs: { name: string; type: string }[] = [];
for (const [name, declarations] of sourceFile.getExportedDeclarations()) {
if (hardcodeExportType[name]) {
exportDefs.push({
name,
type: hardcodeExportType[name],
});
continue;
}
console.log('parsing:', name);
const typeDef = declarations
.map((d) => {
if (d.isKind(SyntaxKind.FunctionDeclaration)) {
// 如果是方法导出
return d
.getType()
.getCallSignatures()
.map((s) => {
let fnText = '';
const typeParameters = s.getTypeParameters();
if (typeParameters.length > 0) {
fnText += `<${typeParameters
.map((tp) => tp.getText())
.join(', ')}>`;
}
fnText += `(${s
.getParameters()
.map((p) => {
return parseFunctionParameter(p);
})
.join(', ')}) => ${s.getReturnType().getText()}`;
return fnText;
})
.join(' | ');
} else {
// 其他
return d.getType().getText();
}
})
.join(' | ');
exportDefs.push({
name,
type: typeDef,
});
}
return { exportDefs, project };
}
/**
* 解析函数参数为字符串
*/
function parseFunctionParameter(parameter: Symbol): string {
const name = parameter.getName();
const isOptional = parameter.isOptional();
const type = parseType(parameter.getDeclarations()[0].getType());
if (isOptional) {
return `${name}?: ${type}`;
} else {
return `${name}: ${type}`;
}
}
/**
* 解析函数
*/
function parseType(type: Type<ts.Type>): string {
if (type.isAnonymous()) {
return type.getText();
}
if (type.isObject()) {
const properties = type.getApparentProperties();
debugger;
return `{ ${properties
.map((p) => {
const t = p.getDeclarations()[0].getType();
const text = parseType(t);
return `${p.getName()}: ${text}`;
})
.join(', ')} }`;
}
return type.getText();
}

View File

@@ -0,0 +1,191 @@
import ts, { isVariableStatement } from 'typescript';
import fs from 'fs-extra';
/**
* Tools: https://ts-ast-viewer.com/
*/
export interface ExportModuleItem {
name: string;
comment?: string;
pos: number;
}
export interface DeclarationModuleItem {
name: string;
text: string;
comment?: string;
pos?: number;
}
export function parseModuleDeclaration(
filePath: string,
options: ts.CompilerOptions
) {
const { program } = parseFile(filePath, options);
const modules: Record<string, DeclarationModuleItem[]> = {};
const sourceFile = program?.getSourceFile(filePath);
sourceFile?.forEachChild((node) => {
if (
ts.isModuleDeclaration(node) &&
node.body &&
ts.isModuleBlock(node.body)
) {
const moduleName = node.name.text;
if (!modules[moduleName]) {
modules[moduleName] = [];
}
node.body.forEachChild((item) => {
if (ts.isVariableStatement(item)) {
let comment: string | undefined = undefined;
const commentRange = ts.getLeadingCommentRanges(
sourceFile.getFullText(),
item.pos
);
if (Array.isArray(commentRange) && commentRange.length > 0) {
comment = '';
commentRange.map(({ pos, end }) => {
comment += sourceFile.text.substring(pos, end);
});
}
item.declarationList.declarations.forEach((declaration) => {
const name = declaration.name.getText();
const pos = declaration.pos;
modules[moduleName].push({
name,
text: declaration.getText(),
comment,
pos,
});
});
}
});
}
});
return { modules };
}
/**
* 解析导出文件
*/
export function parseExports(filePath: string, options: ts.CompilerOptions) {
const { program, service } = parseFile(filePath, options);
const exportModules: ExportModuleItem[] = [];
const sourceFile = program?.getSourceFile(filePath);
sourceFile?.forEachChild((node) => {
if (ts.isExportDeclaration(node)) {
// 如果为导出声明: export { foo } from 'foo'
node.exportClause?.forEachChild((exportSpec) => {
if (ts.isExportSpecifier(exportSpec)) {
exportModules.push({
name: exportSpec.name.text,
// comment:
pos: exportSpec.pos,
});
}
});
} else if (isExportFunc(node)) {
// 如果是方法导出: export function foo() {}
if (node.name) {
exportModules.push({
name: node.name.text,
comment: getNodeComments(node),
pos: node.pos,
});
}
} else if (
isVariableStatement(node) &&
node.modifiers?.some((v) => v.kind === ts.SyntaxKind.ExportKeyword)
) {
// 如果为导出变量
// export const foo = ''
node.declarationList.declarations.forEach((d) => {
if (ts.isIdentifier(d.name)) {
exportModules.push({
name: d.name.getText(),
pos: d.pos,
});
} else {
d.name.elements.forEach((n) => {
exportModules.push({
name: n.getText(),
pos: d.pos,
});
});
}
});
}
});
return { exportModules };
}
/**
* 解析文件
*/
export function parseFile(filePath: string, options: ts.CompilerOptions) {
const host = new FileServiceHost(filePath, options);
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
const program = service.getProgram();
return { service, program };
}
function isExportFunc(node: ts.Node): node is ts.FunctionDeclaration {
if (ts.isFunctionDeclaration(node)) {
if (node.modifiers) {
return node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
}
}
return false;
}
function getNodeComments(node: ts.Node): string | undefined {
const comments = ts.getSyntheticLeadingComments(node);
if (!comments) {
return undefined;
}
return comments.map((c) => c.text).join('\n');
}
class FileServiceHost implements ts.LanguageServiceHost {
constructor(public filePath: string, private options: ts.CompilerOptions) {}
getCompilationSettings = () => this.options;
getScriptFileNames = () => [
this.filePath,
// For test
'/Users/moonrailgun/inventory/tailchat/packages/plugin-declaration-generator/test/demo/foo.ts',
'/Users/moonrailgun/inventory/tailchat/packages/plugin-declaration-generator/test/demo/bar.ts',
];
getScriptVersion = () => '1';
getScriptSnapshot = (fileName: string) => {
if (!fs.existsSync(fileName)) {
return undefined;
}
return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName).toString());
};
// getCurrentDirectory = () => process.cwd();
getCurrentDirectory = () =>
// For test
'/Users/moonrailgun/inventory/tailchat/packages/plugin-declaration-generator/test/demo/';
getDefaultLibFileName = (options: ts.CompilerOptions) =>
ts.getDefaultLibFilePath(options);
readFile(path: string): string | undefined {
return fs.readFileSync(path).toString();
}
fileExists(path: string): boolean {
return fs.existsSync(path);
}
}

View File

@@ -0,0 +1,26 @@
/**
* This is bar
*/
export function bar() {
console.log('Anything else');
}
interface E {
f: symbol;
}
interface Options {
a: number;
b: string;
c: {
d: string;
e: E;
};
}
/**
* This is bar with complex input
*/
export function complexBar(input: Options) {
console.log('Anything else', input);
}

View File

@@ -0,0 +1,13 @@
import * as mkdirp from 'mkdirp';
/**
* This is foo
*/
export function foo(input: string) {
console.log('Anything', input);
mkdirp('./foo/foo/foo/foo/foo/foo/foo');
return input + 1;
}
export const fooVar = 'fooVar' as string;

View File

@@ -0,0 +1,9 @@
export { foo, fooVar } from '@/foo';
export { bar, complexBar } from '@/bar';
/**
* Root export
*/
export function main() {
console.log('main');
}

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./*"],
}
}
}

View File

@@ -0,0 +1,4 @@
declare module '@capital/foo' {
export const a: any;
export const b: string;
}

View File

@@ -0,0 +1,20 @@
import { parseExports, parseModuleDeclaration } from '../src/tsgenerator';
import path from 'path';
const { exportModules } = parseExports(
path.resolve(__dirname, './demo/index.ts'),
{
paths: { '@/*': ['./*'] },
}
);
console.log('exportModules', exportModules);
const { modules } = parseModuleDeclaration(
path.resolve(__dirname, './index.d.ts'),
{
paths: { '@/*': ['./*'] },
}
);
console.log('modules', modules);

View File

@@ -0,0 +1,14 @@
import { parseDeclarationEntry } from '../src/parser';
import path from 'path';
const project = parseDeclarationEntry({
entryPath: path.resolve(__dirname, './demo/index.ts'),
project: {
tsConfigFilePath: path.resolve(__dirname, './demo/tsconfig.json'),
},
});
console.log(
'sourceFile',
project.getSourceFiles().map((item) => item.getFilePath())
);

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["ESNext"],
"outDir": "lib",
"declaration": true,
"esModuleInterop": true,
"isolatedModules": true,
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"importsNotUsedAsValues": "error",
},
"include": ["./src/*"]
}

View File

@@ -0,0 +1 @@
lib

View File

@@ -0,0 +1,3 @@
## Document
visit website to learn more: [https://tailchat.msgbyte.com/docs/advanced-usage/openapp/about](https://tailchat.msgbyte.com/docs/advanced-usage/openapp/about)

View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@@ -0,0 +1,27 @@
{
"name": "tailchat-client-sdk",
"version": "1.0.9",
"description": "",
"main": "lib/index.js",
"scripts": {
"prepare": "tsc",
"release": "npm publish --registry https://registry.npmjs.com/",
"test": "jest"
},
"keywords": [],
"author": "moonrailgun <moonrailgun@gmail.com>",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.1",
"@types/node": "^18.16.1",
"jest": "27.5.1",
"ts-jest": "27.1.4",
"typescript": "^4.9.5"
},
"dependencies": {
"axios": "^1.3.2",
"tailchat-types": "workspace:*",
"socket.io-client": "^4.7.1",
"socket.io-msgpack-parser": "^3.0.2"
}
}

View File

@@ -0,0 +1,21 @@
import { stripMentionTag } from '../utils';
describe('stripMentionTag', () => {
test('simple', () => {
expect(
stripMentionTag('[at=6448e822834c12425646f473]Robot[/at] Hello')
).toBe('Hello');
});
test('not remove other message', () => {
expect(
stripMentionTag(
'[at=6448e822834c12425646f473]Robot[/at] Hello [at=6448e822834c12425646f4732]Robot[/at]'
)
).toBe('Hello [at=6448e822834c12425646f4732]Robot[/at]');
});
test('also can remove mention ', () => {
expect(stripMentionTag('@Robot Hello')).toBe('Hello');
});
});

View File

@@ -0,0 +1,3 @@
export * from './openapi';
export * from './plugins/simplenotify';
export * from './utils';

View File

@@ -0,0 +1,162 @@
import axios, { AxiosInstance } from 'axios';
import crypto from 'crypto';
export class TailchatBaseClient {
request: AxiosInstance;
jwt: string | null = null;
userId: string | null = null;
loginP: Promise<void>;
constructor(
public url: string,
public appId: string,
public appSecret: string
) {
if (!url || !appId || !appSecret) {
throw new Error(
'Require params: apiUrl, appId, appSecret. You can set it with env'
);
}
this.request = axios.create({
baseURL: url,
});
this.request.interceptors.request.use(async (val) => {
if (
this.jwt &&
['post', 'get'].includes(String(val.method).toLowerCase()) &&
!val.headers['X-Token']
) {
// 任何请求都尝试增加token
val.headers['X-Token'] = this.jwt;
}
return val;
});
this.loginP = this.login();
}
async login() {
try {
console.log('Login...');
const { data } = await this.request.post('/api/openapi/bot/login', {
appId: this.appId,
token: this.getBotToken(),
});
// NOTICE: 注意有30天过期时间需要定期重新登录以换取新的token
// 这里先不换
this.jwt = data.data?.jwt;
this.userId = data.data?.userId;
console.log('tailchat openapp login success!');
// 尝试调用函数
// this.whoami().then(console.log);
} catch (err) {
console.error(err);
throw new Error(
`Login failed, please check application credentials or network(Error: ${String(
err
)})`
);
}
}
async waitingForLogin(): Promise<void> {
await Promise.resolve(this.loginP);
}
async call(action: string, params = {}) {
try {
await this.waitingForLogin();
console.log('Calling:', action);
const { data } = await this.request.post(
'/api/' + action.replace(/\./g, '/'),
params
);
return data.data;
} catch (err: any) {
console.error('Service Call Failed:', err);
const data: string = err?.response?.data;
if (data) {
throw new Error(
JSON.stringify({
action,
data,
})
);
} else {
throw err;
}
}
}
async whoami(): Promise<{
userAgent: string;
language: string;
user: {
_id: string;
nickname: string;
email: string;
avatar: string;
};
token: string;
userId: string;
}> {
return this.call('user.whoami');
}
getBotToken() {
return crypto
.createHash('md5')
.update(this.appId + this.appSecret)
.digest('hex');
}
/**
* Send normal message to tailchat
*/
async sendMessage(payload: {
converseId: string;
groupId?: string;
content: string;
plain?: string;
meta?: object;
}) {
return this.call('chat.message.sendMessage', payload);
}
/**
* Reply message
*/
async replyMessage(
replyInfo: {
messageId: string;
author: string;
content: string;
},
payload: {
converseId: string;
groupId?: string;
content: string;
plain?: string;
meta?: object;
}
) {
return this.sendMessage({
...payload,
meta: {
...payload.meta,
mentions: [replyInfo.author],
reply: {
_id: replyInfo.messageId,
author: replyInfo.author,
content: replyInfo.content,
},
},
content: `[at=${replyInfo.author}][/at] ${payload.content}`,
});
}
}

View File

@@ -0,0 +1,3 @@
import { TailchatBaseClient } from './base';
export class TailchatHTTPClient extends TailchatBaseClient {}

View File

@@ -0,0 +1,8 @@
export {
/**
* @deprecated please rename to TailchatHTTPClient
*/
TailchatHTTPClient as TailchatClient,
TailchatHTTPClient,
} from './http';
export { TailchatWsClient } from './ws';

View File

@@ -0,0 +1,111 @@
import { TailchatBaseClient } from './base';
import io, { Socket } from 'socket.io-client';
import * as msgpackParser from 'socket.io-msgpack-parser';
import type { ChatMessage } from 'tailchat-types';
export class TailchatWsClient extends TailchatBaseClient {
public socket: Socket | null = null;
constructor(
public url: string,
public appId: string,
public appSecret: string,
public disableMsgpack: boolean = false
) {
super(url, appId, appSecret);
}
connect(): Promise<Socket> {
return new Promise<Socket>(async (resolve, reject) => {
await this.waitingForLogin();
const token = this.jwt;
const socket = (this.socket = io(this.url, {
transports: ['websocket'],
auth: {
token,
},
forceNew: true,
parser: this.disableMsgpack ? undefined : msgpackParser,
}));
socket.once('connect', () => {
// 连接成功
this.emit('chat.converse.findAndJoinRoom')
.then((res) => {
console.log('Joined rooms', res.data);
resolve(socket);
})
.catch((err) => {
reject(err);
});
});
socket.once('error', () => {
reject();
});
socket.on('disconnect', (reason) => {
console.log(`disconnect due to ${reason}`);
this.socket = null;
});
socket.onAny((ev) => {
console.log('onAny', ev);
});
});
}
disconnect() {
if (!this.socket) {
console.warn('You should call it after connect');
return;
}
this.socket.disconnect();
this.socket = null;
}
emit(eventName: string, eventData: any = {}) {
if (!this.socket) {
console.warn('You should call it after connect');
throw new Error('You should call it after connect');
}
return this.socket.emitWithAck(eventName, eventData);
}
on(eventName: string, callback: (payload: any) => void) {
if (!this.socket) {
console.warn('You should call it after connect');
return;
}
this.socket.on(eventName, callback);
}
once(eventName: string, callback: (payload: any) => void) {
if (!this.socket) {
console.warn('You should call it after connect');
return;
}
this.socket.once(eventName, callback);
}
off(eventName: string, callback: (payload: any) => void) {
if (!this.socket) {
console.warn('You should call it after connect');
return;
}
this.socket.off(eventName, callback);
}
onMessage(callback: (messagePayload: ChatMessage) => void) {
this.on('notify:chat.message.add', callback);
}
onMessageUpdate(callback: (messagePayload: ChatMessage) => void) {
this.on('notify:chat.message.update', callback);
}
}

View File

@@ -0,0 +1 @@
export * from './client';

View File

@@ -0,0 +1,24 @@
import axios from 'axios';
/**
* 基于简易推送插件的消息通知服务
*
* @param hostUrl 实例地址url
* @param subscribeId 订阅id
* @param text 发送的文本默认支持bbcode
*/
export async function sendSimpleNotify(
hostUrl: string,
subscribeId: string,
text: string
) {
await axios({
method: 'post',
baseURL: hostUrl,
url: '/api/plugin:com.msgbyte.simplenotify/webhook/callback',
data: {
subscribeId,
text,
},
});
}

View File

@@ -0,0 +1,10 @@
/**
* remove first [at=xxx]xxx[/at] in message first
*/
export function stripMentionTag(message: string): string {
return message
.trim()
.replace(/^\[at=.*?\[\/at\]/, '')
.replace(/^@\S*\s?/, '')
.trimStart();
}

View File

@@ -0,0 +1,20 @@
import { TailchatWsClient } from '../src';
const HOST = process.env.HOST;
const APPID = process.env.APPID;
const APPSECRET = process.env.APPSECRET;
if (!HOST || !APPID || !APPSECRET) {
console.log('require env: HOST, APPID, APPSECRET');
process.exit(1);
}
const client = new TailchatWsClient(HOST, APPID, APPSECRET);
client.connect().then(async () => {
console.log('Login Success!');
client.onMessage((message) => {
console.log('Receive message', message);
});
});

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["ESNext"],
"skipLibCheck": true,
"outDir": "lib",
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"isolatedModules": true,
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"importsNotUsedAsValues": "error",
"typeRoots": ["./node_modules/@types"]
},
"include": ["./src/*"]
}