feat: bubble组件封装

This commit is contained in:
renxiaodong
2025-08-21 00:24:36 +08:00
parent 6e3158cdb4
commit 64621d9add
17 changed files with 911 additions and 5 deletions

View File

@ -0,0 +1,135 @@
<script lang="tsx">
import { Avatar } from 'ant-design-vue';
import { computed, defineComponent, ref, toRef, unref, watch, watchEffect } from 'vue';
import Loading from './loading.vue';
import useTypingConfig from './hooks/useTypingConfig';
import useTypedEffect from './hooks/useTypedEffect';
import { useBubbleContextInject } from './context';
import type { BubbleProps, BubbleContentType, SlotInfoType } from './types';
export default defineComponent({
name: 'Bubble',
inheritAttrs: false,
props: {} as any,
setup(_, { attrs, slots }) {
const props = attrs as unknown as BubbleProps<BubbleContentType> & { style?: any; class?: any };
const content = ref<BubbleContentType>(props.content ?? '');
watch(
() => props.content,
(val) => {
content.value = val as any;
},
{ immediate: true },
);
const { onUpdate } = unref(useBubbleContextInject());
const { typing: typingProp, loading } = props;
const [typingEnabled, typingStep, typingInterval, typingSuffix] = useTypingConfig(() => typingProp);
const [typedContent, isTyping] = useTypedEffect(content as any, typingEnabled, typingStep, typingInterval);
const triggerTypingCompleteRef = ref(false);
watch(typedContent, () => {
onUpdate?.();
});
watchEffect(() => {
if (!isTyping.value && !loading) {
if (!triggerTypingCompleteRef.value) {
triggerTypingCompleteRef.value = true;
props.onTypingComplete?.();
}
} else {
triggerTypingCompleteRef.value = false;
}
});
const prefixCls = 'xt-bubble';
const mergedCls = computed(() => [
prefixCls,
`${prefixCls}-${props.placement ?? 'start'}`,
props.classNames?.root,
props.class,
{
[`${prefixCls}-typing`]:
isTyping.value && !loading && !props.messageRender && !slots.message && !typingSuffix.value,
},
]);
const avatarNode = computed(() => {
if (slots.avatar) return slots.avatar();
const avatar = props.avatar as any;
return typeof avatar === 'function' ? avatar() : avatar && avatar.src ? <Avatar {...avatar} /> : null;
});
const mergedContent = computed(() => {
if (slots.message) return slots.message({ content: typedContent.value as any }) as any;
return props.messageRender
? (props.messageRender(typedContent.value as any) as any)
: (typedContent.value as any);
});
const contentNode = computed(() => {
if (props.loading) {
return slots.loading?.() ?? props.loadingRender?.() ?? <Loading prefixCls={prefixCls} />;
}
return (
<>
{mergedContent.value as any}
{isTyping.value && unref(typingSuffix)}
</>
);
});
const renderHeader = () => {
const info: SlotInfoType = { key: props._key };
if (slots.header) return slots.header({ content: typedContent.value as any, info }) as any;
const h = props.header as any;
return typeof h === 'function' ? h(typedContent.value as any, info) : h;
};
const renderFooter = () => {
const info: SlotInfoType = { key: props._key };
if (slots.footer) return slots.footer({ content: typedContent.value as any, info }) as any;
const f = props.footer as any;
return typeof f === 'function' ? f(typedContent.value as any, info) : f;
};
return () => (
<div class={mergedCls.value} style={{ ...(props.styles?.root || {}), ...(props.style || {}) }}>
{(slots.avatar || props.avatar) && (
<div class={[`${prefixCls}-avatar`, props.classNames?.avatar]} style={props.styles?.avatar}>
{avatarNode.value as any}
</div>
)}
<div
class={[
`${prefixCls}-content`,
`${prefixCls}-content-${props.variant ?? 'filled'}`,
{ [`${prefixCls}-content-${props.shape}`]: props.shape },
props.classNames?.content,
]}
style={props.styles?.content}
>
{renderHeader() ? (
<div class={[`${prefixCls}-header`, props.classNames?.header]} style={props.styles?.header}>
{renderHeader()}
</div>
) : null}
{contentNode.value as any}
{renderFooter() ? (
<div class={[`${prefixCls}-footer`, props.classNames?.footer]} style={props.styles?.footer}>
{renderFooter()}
</div>
) : null}
</div>
</div>
);
},
});
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,159 @@
<script lang="tsx">
import Bubble from './Bubble.vue';
import BubbleContextProvider from './context';
import useDisplayData from './hooks/useDisplayData';
import useListData from './hooks/useListData';
import useState from '@/hooks/useState';
import pickAttrs from '@/utils/pick-attrs';
import { useEventCallback } from '@/hooks/useEventCallback';
import type { BubbleListProps, BubbleRef, RolesType } from './types';
import {
computed,
defineComponent,
mergeProps,
nextTick,
onWatcherCleanup,
ref,
unref,
useAttrs,
watch,
watchPostEffect,
} from 'vue';
export default defineComponent({
name: 'BubbleList',
inheritAttrs: false,
props: {} as any,
setup(_, { attrs, slots }) {
const props = attrs as unknown as BubbleListProps & { class?: any; style?: any };
const passThroughAttrs = useAttrs();
const TOLERANCE = 1;
const domProps = computed(() => pickAttrs(mergeProps(props as any, passThroughAttrs)) as any);
const items = ref(props.items);
const roles = ref(props.roles as RolesType);
watch(
() => props.items,
(val) => (items.value = val),
{ immediate: true },
);
watch(
() => props.roles,
(val) => (roles.value = val as RolesType),
{ immediate: true },
);
const listRef = ref<HTMLDivElement | null>(null);
const bubbleRefs = ref<Record<string | number, BubbleRef>>({});
const prefixCls = 'xt-bubble';
const listPrefixCls = `${prefixCls}-list`;
const [initialized, setInitialized] = useState(false);
watchPostEffect(() => {
setInitialized(true);
onWatcherCleanup(() => setInitialized(false));
});
// data
const mergedData = useListData(items as any, roles as any);
const [displayData, onTypingComplete] = useDisplayData(mergedData);
// scroll
const [scrollReachEnd, setScrollReachEnd] = useState(true);
const [updateCount, setUpdateCount] = useState(0);
const onInternalScroll = (e: Event) => {
const target = e.target as HTMLElement;
setScrollReachEnd(target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight <= TOLERANCE);
};
watch(updateCount, () => {
if ((props.autoScroll ?? true) && unref(listRef) && unref(scrollReachEnd)) {
nextTick(() => {
unref(listRef)!.scrollTo({ top: unref(listRef)!.scrollHeight });
});
}
});
watch(
() => unref(displayData).length,
() => {
if (props.autoScroll ?? true) {
const lastItemKey = unref(displayData)[unref(displayData).length - 2]?.key;
const bubbleInst = unref(bubbleRefs)[lastItemKey!];
if (bubbleInst) {
const { nativeElement } = bubbleInst;
const { top = 0, bottom = 0 } = nativeElement?.getBoundingClientRect() ?? {};
const { top: listTop, bottom: listBottom } = unref(listRef)!.getBoundingClientRect();
const isVisible = top < listBottom && bottom > listTop;
if (isVisible) {
setUpdateCount(unref(updateCount) + 1);
setScrollReachEnd(true);
}
}
}
},
);
const onBubbleUpdate = useEventCallback<void>(() => {
if (props.autoScroll ?? true) setUpdateCount(unref(updateCount) + 1);
});
const context = computed(() => ({ onUpdate: onBubbleUpdate }));
return () => (
<BubbleContextProvider value={context.value}>
<div
{...domProps.value}
class={[
listPrefixCls,
props.rootClassName,
props.class,
{ [`${listPrefixCls}-reach-end`]: unref(scrollReachEnd) },
]}
style={props.style as any}
ref={listRef}
onScroll={onInternalScroll}
>
{unref(displayData).map(({ key, onTypingComplete: onTypingCompleteBubble, ...bubble }: any) => (
<Bubble
{...bubble}
avatar={slots.avatar ? () => slots.avatar?.({ item: { key, ...bubble } }) : (bubble as any).avatar}
header={slots.header?.({ item: { key, ...bubble } }) ?? (bubble as any).header}
footer={slots.footer?.({ item: { key, ...bubble } }) ?? (bubble as any).footer}
loadingRender={
slots.loading ? () => slots.loading({ item: { key, ...bubble } }) : (bubble as any).loadingRender
}
content={slots.message?.({ item: { key, ...bubble } }) ?? (bubble as any).content}
key={key}
ref={(node: any) => {
if (node) {
bubbleRefs.value[key] = node as BubbleRef;
} else {
delete bubbleRefs.value[key];
}
}}
typing={unref(initialized) ? (bubble as any).typing : false}
onTypingComplete={() => {
onTypingCompleteBubble?.();
onTypingComplete(key);
}}
/>
))}
</div>
</BubbleContextProvider>
);
},
});
</script>
<style scoped lang="scss">
@import './style.scss';
</style>
<!--
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:39:35
-->

View File

@ -0,0 +1,45 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:17:49
*/
import { computed, defineComponent, inject, provide, shallowRef, triggerRef, unref, watch } from "vue";
import type { ComputedRef, InjectionKey } from "vue";
import { objectType } from "@/utils/type";
import type { BubbleContextProps } from "./types";
const BubbleContextKey: InjectionKey<ComputedRef<BubbleContextProps>> =
Symbol('BubbleContext');
export const globalBubbleContextApi = shallowRef<BubbleContextProps>();
export const useBubbleContextProvider = (value: ComputedRef<BubbleContextProps>) => {
provide(BubbleContextKey, value);
watch(
value,
() => {
globalBubbleContextApi.value = unref(value);
triggerRef(globalBubbleContextApi);
},
{ immediate: true, deep: true },
);
};
export const useBubbleContextInject = () => {
return inject(
BubbleContextKey,
computed(() => globalBubbleContextApi.value || {}),
);
};
export const BubbleContextProvider = defineComponent({
props: {
value: objectType<BubbleContextProps>(),
},
setup(props, { slots }) {
useBubbleContextProvider(computed(() => props.value));
return () => {
return slots.default?.();
};
},
});
export default BubbleContextProvider;

View File

@ -0,0 +1,58 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:12:42
*/
import { computed, unref, watch } from 'vue';
import type { Ref } from 'vue';
import { useEventCallback } from '@/hooks/useEventCallback';
import useState from '@/hooks/useState';
import type { ListItemType } from './useListData';
type UseDisplayDataReturn = [Ref<ListItemType[]>, (value: string | number) => void];
export default function useDisplayData(items: Ref<ListItemType[]>): UseDisplayDataReturn {
const [displayCount, setDisplayCount] = useState(items.value.length);
const displayList = computed(() => items.value.slice(0, unref(displayCount)));
const displayListLastKey = computed(() => {
const lastItem = unref(displayList)[unref(displayList).length - 1];
return lastItem ? lastItem.key : null;
});
// When `items` changed, we replaced with latest one
watch(
items,
() => {
setDisplayCount(items.value.length);
if (
unref(displayList).length &&
unref(displayList).every((item, index) => item.key === items.value[index]?.key)
) {
return;
}
if (unref(displayList).length === 0) {
setDisplayCount(1);
} else {
// Find diff index
for (let i = 0; i < unref(displayList).length; i += 1) {
if (unref(displayList)[i].key !== items.value[i]?.key) {
setDisplayCount(i);
break;
}
}
}
},
{ immediate: true, deep: true },
);
// Continue to show if last one finished typing
const onTypingComplete = useEventCallback((key: string | number) => {
if (key === unref(displayListLastKey)) {
setDisplayCount(unref(displayCount) + 1);
}
});
return [displayList, onTypingComplete] as const;
}

View File

@ -0,0 +1,41 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:14:55
*/
import { computed, type Ref } from 'vue';
import type { BubbleDataType, BubbleListProps } from '../types';
import type { BubbleProps } from '../types';
export type UnRef<T extends Ref<any>> = T extends Ref<infer R> ? R : never;
export type ListItemType = UnRef<ReturnType<typeof useListData>>[number];
export default function useListData(
items: Ref<BubbleListProps['items']>,
roles?: Ref<BubbleListProps['roles']>,
) {
const getRoleBubbleProps = (bubble: BubbleDataType, index: number): Partial<BubbleProps> => {
if (typeof roles.value === 'function') {
return roles.value(bubble, index);
}
if (roles) {
return roles.value?.[bubble.role!] || {};
}
return {};
}
const listData = computed(() =>
(items.value || []).map((bubbleData, i) => {
const mergedKey = bubbleData.key ?? `preset_${i}`;
return {
...getRoleBubbleProps(bubbleData, i),
...bubbleData,
key: mergedKey,
};
}));
return listData as Ref<any[]>;
}

View File

@ -0,0 +1,75 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:26:23
*/
import useState from '@/hooks/useState';
import { computed, onWatcherCleanup, unref, watch } from 'vue';
import type { Ref } from 'vue';
import type { BubbleContentType } from '../types';
function isString(str: any): str is string {
return typeof str === 'string';
}
/**
* Return typed content and typing status when typing is enabled.
* Or return content directly.
*/
const useTypedEffect = (
content: Ref<BubbleContentType>,
typingEnabled: Ref<boolean>,
typingStep: Ref<number>,
typingInterval: Ref<number>,
): [typedContent: Ref<BubbleContentType>, isTyping: Ref<boolean>] => {
const [prevContent, setPrevContent] = useState<BubbleContentType>('');
const [typingIndex, setTypingIndex] = useState<number>(1);
const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value));
// Reset typing index when content changed
watch(
content,
() => {
const prevContentValue = unref(prevContent);
setPrevContent(content.value);
if (!mergedTypingEnabled.value && isString(content.value)) {
setTypingIndex(content.value.length);
} else if (
isString(content.value) &&
isString(prevContentValue) &&
content.value.indexOf(prevContentValue) !== 0
) {
setTypingIndex(1);
}
},
{ immediate: true },
);
// Start typing
watch(
[typingIndex, typingEnabled, content],
() => {
if (mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length) {
const id = setTimeout(() => {
setTypingIndex(unref(typingIndex) + typingStep.value);
}, typingInterval.value);
onWatcherCleanup(() => {
clearTimeout(id);
});
}
},
{ immediate: true },
);
const mergedTypingContent = computed(() =>
mergedTypingEnabled.value && isString(content.value) ? content.value.slice(0, unref(typingIndex)) : content.value,
);
return [
mergedTypingContent,
computed(() => mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length),
];
};
export default useTypedEffect;

View File

@ -0,0 +1,37 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:27:05
*/
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import type { BubbleProps, TypingOption } from '../types';
function useTypingConfig(typing: MaybeRefOrGetter<BubbleProps['typing']>) {
const typingEnabled = computed(() => {
if (!toValue(typing)) {
return false;
}
return true;
});
const baseConfig: Required<TypingOption> = {
step: 1,
interval: 50,
// set default suffix is empty
suffix: null,
};
const config = computed(() => {
const typingRaw = toValue(typing);
return {
...baseConfig,
...(typeof typingRaw === 'object' ? typingRaw : {})
}
});
return [
typingEnabled,
computed(() => config.value.step),
computed(() => config.value.interval),
computed(() => config.value.suffix)
] as const;
}
export default useTypingConfig;

View File

@ -0,0 +1,9 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:16:14
*/
export { default as Bubble } from './Bubble.vue';
export { default as BubbleList } from './BubbleList.vue';
export * from './types';

View File

@ -0,0 +1,4 @@
<!--
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:04:55
-->

View File

@ -0,0 +1,29 @@
<!--
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:20:21
-->
<script lang="tsx">
import { defineComponent } from 'vue';
interface LoadingProps {
prefixCls?: string;
}
export default defineComponent({
props: {
prefixCls: {
type: String,
default: '',
},
},
setup(props, { emit, expose }) {
return (
<span class={`${props.prefixCls}-dot`}>
<i class={`${props.prefixCls}-dot-item`} key={`item-${1}`} />
<i class={`${props.prefixCls}-dot-item`} key={`item-${2}`} />
<i class={`${props.prefixCls}-dot-item`} key={`item-${3}`} />
</span>
);
},
});
</script>

View File

@ -0,0 +1,94 @@
.xt-bubble-list {
gap: 8px;
.xt-bubble {
display: flex;
justify-content: start;
margin: 8px 0;
&.xt-bubble-start {
flex-direction: row;
}
&.xt-bubble-end {
justify-content: end;
flex-direction: row-reverse;
}
.xt-bubble-content {
color: var(--Text-1, #211f24);
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
.xt-bubble__avatar {
margin: 0 8px;
img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
}
&.xt-bubble-content-round {
display: flex;
justify-content: center;
align-items: center;
border-radius: 50px;
background: var(--BG-200, #f2f3f5);
padding-inline: 0;
}
}
}
}
.xt-bubble--filled .xt-bubble__content {
background-color: #f5f5f5;
}
.xt-bubble--outlined .xt-bubble__content {
background-color: #fff;
border: 1px solid #e5e6eb;
}
.xt-bubble--shadow .xt-bubble__content {
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.xt-bubble--borderless .xt-bubble__content {
background-color: transparent;
}
.xt-bubble--shape-default .xt-bubble__content {
border-radius: 10px;
}
.xt-bubble--shape-round .xt-bubble__content {
border-radius: 18px;
}
.xt-bubble--shape-corner .xt-bubble__content {
border-radius: 4px;
}
.xt-bubble__header {
font-size: 12px;
color: #86909c;
margin-bottom: 6px;
}
.xt-bubble__footer {
font-size: 12px;
color: #86909c;
margin-top: 6px;
}
.xt-bubble__typing {
display: inline-block;
min-width: 36px;
}
.xt-bubble-list {
display: flex;
flex-direction: column;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}

View File

@ -0,0 +1,90 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:04:54
*/
import type { AvatarProps } from 'ant-design-vue';
import type { CSSProperties, HTMLAttributes, VNode } from 'vue';
export type AvoidValidation<T> = T;
export interface TypingOption {
/**
* @default 1
*/
step?: number;
/**
* @default 50
*/
interval?: number;
/**
* @default null
*/
suffix?: VNode | string;
}
export type SemanticType = 'avatar' | 'content' | 'header' | 'footer';
export type BubbleContentType = VNode | string | Record<PropertyKey, any> | number;
export type SlotInfoType = {
key?: string | number;
};
export interface _AvatarProps extends AvatarProps {
class: string;
style: CSSProperties;
}
export interface BubbleProps<ContentType extends BubbleContentType = string> extends /* @vue-ignore */ Omit<HTMLAttributes, 'content'> {
prefixCls?: string;
rootClassName?: string;
styles?: Partial<Record<SemanticType, CSSProperties>>;
classNames?: Partial<Record<SemanticType, string>>;
avatar?: Partial<_AvatarProps> | VNode | (() => VNode);
placement?: 'start' | 'end';
loading?: boolean;
typing?: AvoidValidation<TypingOption | boolean>;
content?: ContentType;
messageRender?: (content: ContentType) => VNode | string;
loadingRender?: () => VNode;
variant?: 'filled' | 'borderless' | 'outlined' | 'shadow';
shape?: 'round' | 'corner';
_key?: number | string;
onTypingComplete?: VoidFunction;
header?: AvoidValidation<VNode | string | ((content: ContentType, info: SlotInfoType) => VNode | string)>;
footer?: AvoidValidation<VNode | string | ((content: ContentType, info: SlotInfoType) => VNode | string)>;
}
export interface BubbleRef {
nativeElement: HTMLElement;
}
export interface BubbleContextProps {
onUpdate?: VoidFunction;
}
export interface BubbleListRef {
nativeElement: HTMLDivElement;
scrollTo: (info: {
offset?: number;
key?: string | number;
behavior?: ScrollBehavior;
block?: ScrollLogicalPosition;
}) => void;
}
export type BubbleDataType = BubbleProps<any> & {
key?: string | number;
role?: string;
};
export type RoleType = Partial<Omit<BubbleProps<any>, 'content'>>;
export type RolesType = Record<string, RoleType> | ((bubbleDataP: BubbleDataType, index: number) => RoleType);
export interface BubbleListProps extends /* @vue-ignore */ HTMLAttributes {
prefixCls?: string;
rootClassName?: string;
items?: BubbleDataType[];
autoScroll?: boolean;
roles?: AvoidValidation<RolesType>;
}