Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 122 additions & 120 deletions packages/core/src/components/BubbleList/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const props = withDefaults(defineProps<BubbleListProps<T>>(), {

const emits = defineEmits<BubbleListEmits>();

// 包装list (反转一下, 使渲染顺序与真实顺序一致)
const wrapList = computed(() => Array.from(props.list).reverse());

function initStyle() {
document.documentElement.style.setProperty(
'--el-bubble-list-max-height',
Expand All @@ -35,6 +38,11 @@ function initStyle() {
);
}

// 获取真实的索引
function getTrueIndex(index: number) {
return wrapList.value.length - 1 - index;
}

onMounted(() => {
initStyle();
});
Expand All @@ -51,91 +59,80 @@ const lastScrollTop = ref(0);
const accumulatedScrollUpDistance = ref(0);
// 阈值(像素)
const threshold = 20;
const resizeObserver = ref<ResizeObserver | null>(null);
const showBackToBottom = ref(false); // 控制按钮显示
// 返回顶部显示
const showBackToBottom = ref(false);

// 监听数组长度变化,如果改变,则判断是否在最底部,如果在,就自动滚动到底部
watch(
() => props.list.length,
() => {
if (props.list && props.list.length > 0) {
nextTick(() => {
// 每次添加新的气泡,等页面渲染后,在执行自动滚动
autoScroll();
});
}
},
{ immediate: true }
);
// 设置容器滚动距离
function setContainerScrollTop(num: number) {
const container = scrollContainer.value;
if (!container) return;
container.scrollTop = num;
}

// 父组件的触发方法,直接让滚动容器滚动到顶部
function scrollToTop() {
// 处理在滚动时候,无法回到顶部的问题
stopAutoScrollToBottom.value = true;
nextTick(() => {
// 自动滚动到最顶部
scrollContainer.value!.scrollTop = 0;
if (scrollContainer.value && scrollContainer.value.scrollHeight) {
// 自动滚动到最顶部
setContainerScrollTop(-scrollContainer.value!.scrollHeight);
}
});
}
// 父组件的触发方法,不跟随打字器滚动,滚动底部

// 是否是通过方法滚动的
function scrollToBottom() {
try {
if (scrollContainer.value && scrollContainer.value.scrollHeight) {
nextTick(() => {
scrollContainer.value!.scrollTop = scrollContainer.value!.scrollHeight;
// 修复清空BubbleList后,再次调用 scrollToBottom(),不触发自动滚动问题
stopAutoScrollToBottom.value = false;
});
}
} catch (error) {
console.warn('[BubbleList error]: ', error);
}
const container = scrollContainer.value;
if (!container) return;
setContainerScrollTop(0);
autoScroll();
}
// 父组件触发滚动到指定气泡框
function scrollToBubble(index: number) {
const container = scrollContainer.value;
if (!container) return;

const bubbles = container.querySelectorAll('.el-bubble');

if (index >= bubbles.length) return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix the bounds check to use the correct list length.

The bounds check should compare against wrapList.value.length instead of bubbles.length for consistency.

-  if (index >= bubbles.length) return;
+  if (index >= wrapList.value.length) return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (index >= bubbles.length) return;
if (index >= wrapList.value.length) return;
🧰 Tools
🪛 ESLint

[error] 98-98: Expect newline after if

(antfu/if-newline)

🤖 Prompt for AI Agents
In packages/core/src/components/BubbleList/index.vue at line 98, the bounds
check incorrectly compares the index against bubbles.length instead of
wrapList.value.length. Update the condition to use wrapList.value.length to
ensure consistency with the list being referenced.


stopAutoScrollToBottom.value = true;
const targetBubble = bubbles[index] as HTMLElement;
const targetBubble = bubbles[getTrueIndex(index)] as HTMLElement;

// 计算相对位置
const containerRect = container.getBoundingClientRect();
const bubbleRect = targetBubble.getBoundingClientRect();

// 计算需要滚动的距离(元素顶部相对于容器顶部的位置 - 容器当前滚动位置
// 计算需要滚动的距离(容器当前滚动位置) - 元素顶部相对于容器顶部的位置
const scrollPosition =
bubbleRect.top - containerRect.top + container.scrollTop;
container.scrollTop - (containerRect.top - bubbleRect.top);

// 使用容器自己的滚动方法
container.scrollTo({
top: scrollPosition,
behavior: 'smooth'
});
setContainerScrollTop(scrollPosition);
}

let mutationObserver: MutationObserver;

// 组件内部触发方法,跟随打字器滚动,滚动底部
function autoScroll() {
if (scrollContainer.value) {
const listBubbles = scrollContainer.value!.querySelectorAll(
'.el-bubble-content-wrapper'
);
// 如果页面上有监听节点,先移除
if (resizeObserver.value) {
resizeObserver.value.disconnect();
const container = scrollContainer.value;
if (!container) return;
if (mutationObserver) mutationObserver.disconnect();
mutationObserver = new MutationObserver(() => {
if (stopAutoScrollToBottom.value) {
mutationObserver.disconnect();
return;
}
const lastItem = listBubbles[listBubbles.length - 1];
if (lastItem) {
resizeObserver.value = new ResizeObserver(() => {
if (!stopAutoScrollToBottom.value) {
scrollToBottom();
}
});
resizeObserver.value.observe(lastItem);
const scrollTop = Math.abs(container.scrollTop);
if (scrollTop > 0) {
setContainerScrollTop(0);
} else {
mutationObserver.disconnect();
}
}
});
mutationObserver.observe(container, {
childList: true,
subtree: true
});
}
Comment on lines +113 to 136
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure MutationObserver is properly cleaned up.

The MutationObserver should be disconnected when the component unmounts to prevent memory leaks.

Add cleanup in the component lifecycle:

+onUnmounted(() => {
+  if (mutationObserver) {
+    mutationObserver.disconnect();
+  }
+});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mutationObserver: MutationObserver;
// 组件内部触发方法,跟随打字器滚动,滚动底部
function autoScroll() {
if (scrollContainer.value) {
const listBubbles = scrollContainer.value!.querySelectorAll(
'.el-bubble-content-wrapper'
);
// 如果页面上有监听节点,先移除
if (resizeObserver.value) {
resizeObserver.value.disconnect();
const container = scrollContainer.value;
if (!container) return;
if (mutationObserver) mutationObserver.disconnect();
mutationObserver = new MutationObserver(() => {
if (stopAutoScrollToBottom.value) {
mutationObserver.disconnect();
return;
}
const lastItem = listBubbles[listBubbles.length - 1];
if (lastItem) {
resizeObserver.value = new ResizeObserver(() => {
if (!stopAutoScrollToBottom.value) {
scrollToBottom();
}
});
resizeObserver.value.observe(lastItem);
const scrollTop = Math.abs(container.scrollTop);
if (scrollTop > 0) {
setContainerScrollTop(0);
} else {
mutationObserver.disconnect();
}
}
});
mutationObserver.observe(container, {
childList: true,
subtree: true
});
}
let mutationObserver: MutationObserver;
// 组件内部触发方法,跟随打字器滚动,滚动底部
function autoScroll() {
const container = scrollContainer.value;
if (!container) return;
if (mutationObserver) mutationObserver.disconnect();
mutationObserver = new MutationObserver(() => {
if (stopAutoScrollToBottom.value) {
mutationObserver.disconnect();
return;
}
const scrollTop = Math.abs(container.scrollTop);
if (scrollTop > 0) {
setContainerScrollTop(0);
} else {
mutationObserver.disconnect();
}
});
mutationObserver.observe(container, {
childList: true,
subtree: true
});
}
onUnmounted(() => {
if (mutationObserver) {
mutationObserver.disconnect();
}
});
🧰 Tools
🪛 ESLint

[error] 118-118: Expect newline after if

(antfu/if-newline)


[error] 119-119: Expect newline after if

(antfu/if-newline)


[error] 128-128: Closing curly brace appears on the same line as the subsequent block.

(style/brace-style)

🤖 Prompt for AI Agents
In packages/core/src/components/BubbleList/index.vue around lines 113 to 136,
the MutationObserver is created but not disconnected when the component
unmounts, risking memory leaks. To fix this, add a cleanup step in the
component's unmounted lifecycle hook that calls mutationObserver.disconnect() if
the observer exists, ensuring proper resource release.


const completeMap = ref<Record<number, TypewriterInstance>>({});
Expand All @@ -146,6 +143,7 @@ const typingList = computed(() =>
);
// 打字机播放完成回调
function handleBubbleComplete(index: number, instance: TypewriterInstance) {
index = getTrueIndex(index);
switch (props.triggerIndices) {
case 'only-last':
if (index === typingList.value[typingList.value.length - 1]?._index_) {
Expand All @@ -167,50 +165,54 @@ function handleBubbleComplete(index: number, instance: TypewriterInstance) {

// 监听用户滚动事件
function handleScroll() {
if (scrollContainer.value) {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer.value;
const container = scrollContainer.value;
if (!container) return;
let { scrollTop, clientHeight, scrollHeight } = container;

// 获取真实的 scrollTop
scrollTop = scrollHeight - clientHeight - Math.abs(scrollTop);

// 计算是否超过安全距离
const distanceToBottom = scrollHeight - (scrollTop + clientHeight);
showBackToBottom.value =
props.showBackButton && distanceToBottom > props.backButtonThreshold;
// 计算是否超过安全距离
const distanceToBottom = scrollHeight - (scrollTop + clientHeight);
showBackToBottom.value =
props.showBackButton && distanceToBottom > props.backButtonThreshold;

// 判断是否距离底部小于阈值 (这里吸附值大一些会体验更好)
const isCloseToBottom = scrollTop + clientHeight >= scrollHeight - 30;
// 判断用户是否向上滚动
const isScrollingUp = lastScrollTop.value > scrollTop;
// 判断用户是否向下滚动
const isScrollingDown = lastScrollTop.value < scrollTop;
// 计算当前滚动距离的变化
const scrollDelta = lastScrollTop.value - scrollTop;
// 更新上次滚动位置
lastScrollTop.value = scrollTop;
// 处理向上滚动逻辑
if (isScrollingUp) {
// 累积向上滚动距离
accumulatedScrollUpDistance.value += scrollDelta;
// 如果累积距离超过阈值,触发逻辑并重置累积距离
if (accumulatedScrollUpDistance.value >= threshold) {
// console.log(`用户向上滚动超过 ${threshold} 像素(累积)${stopAutoScrollToBottom.value}`)
// 在这里执行你的操作
if (!stopAutoScrollToBottom.value) {
stopAutoScrollToBottom.value = true;
}
// 重置累积距离
accumulatedScrollUpDistance.value = 0;
// 判断是否距离底部小于阈值 (这里吸附值大一些会体验更好)
const isCloseToBottom = scrollTop + clientHeight >= scrollHeight - 30;
// 判断用户是否向上滚动
const isScrollingUp = lastScrollTop.value > scrollTop;
// 判断用户是否向下滚动
const isScrollingDown = lastScrollTop.value < scrollTop;
// 计算当前滚动距离的变化
const scrollDelta = lastScrollTop.value - scrollTop;
// 更新上次滚动位置
lastScrollTop.value = scrollTop;
// 处理向上滚动逻辑
if (isScrollingUp) {
// 累积向上滚动距离
accumulatedScrollUpDistance.value += scrollDelta;
// 如果累积距离超过阈值,触发逻辑并重置累积距离
if (accumulatedScrollUpDistance.value >= threshold) {
// console.log(`用户向上滚动超过 ${threshold} 像素(累积)${stopAutoScrollToBottom.value}`)
// 在这里执行你的操作
if (!stopAutoScrollToBottom.value) {
stopAutoScrollToBottom.value = true;
}
} else {
// 如果用户停止向上滚动或开始向下滚动,重置累积距离
// 重置累积距离
accumulatedScrollUpDistance.value = 0;
}
// 处理向下滚动且接近底部的逻辑
if (isScrollingDown && isCloseToBottom) {
// console.log(`用户向下滚动且距离底部小于 ${threshold} 像素`)
// 在这里执行你的操作
if (stopAutoScrollToBottom.value) {
stopAutoScrollToBottom.value = false;
}
} else {
// 如果用户停止向上滚动或开始向下滚动,重置累积距离
accumulatedScrollUpDistance.value = 0;
}
// 处理向下滚动且接近底部的逻辑
if (isScrollingDown && isCloseToBottom) {
// console.log(`用户向下滚动且距离底部小于 ${threshold} 像素`)
// 在这里执行你的操作
if (stopAutoScrollToBottom.value) {
stopAutoScrollToBottom.value = false;
}
autoScroll();
}
}
/* 在底部时候自动滚动 结束 */
Expand All @@ -229,10 +231,37 @@ defineExpose({
:class="{ 'always-scrollbar': props.alwaysShowScrollbar }"
@scroll="handleScroll"
>
<!-- 自定义按钮插槽 默认返回按钮 -->
<div
v-if="showBackToBottom && hasVertical"
class="el-bubble-list-default-back-button"
:class="{
'el-bubble-list-back-to-bottom-solt': $slots.backToBottom
}"
:style="{
bottom: backButtonPosition.bottom,
left: backButtonPosition.left
}"
@click="scrollToBottom"
>
<!-- 返回到底部 -->
<slot name="backToBottom">
<el-icon
class="el-bubble-list-back-to-bottom-icon"
:style="{ color: props.btnColor }"
>
<ArrowDownBold />
<loadingBg
v-if="props.btnLoading"
class="back-to-bottom-loading-svg-bg"
/>
</el-icon>
</slot>
</div>
<!-- 如果给 BubbleList 的 item 传入 md 配置,则按照 item 的 md 配置渲染 -->
<!-- 否则,则按照 BubbleList 的 md 配置渲染 -->
<Bubble
v-for="(item, index) in list"
v-for="(item, index) in wrapList"
:key="index"
:content="item.content"
:placement="item.placement"
Expand Down Expand Up @@ -269,33 +298,6 @@ defineExpose({
<slot name="loading" :item="item" />
</template>
</Bubble>

<!-- 自定义按钮插槽 默认返回按钮 -->
<div
v-if="showBackToBottom && hasVertical"
class="el-bubble-list-default-back-button"
:class="{
'el-bubble-list-back-to-bottom-solt': $slots.backToBottom
}"
:style="{
bottom: backButtonPosition.bottom,
left: backButtonPosition.left
}"
@click="scrollToBottom"
>
<slot name="backToBottom">
<el-icon
class="el-bubble-list-back-to-bottom-icon"
:style="{ color: props.btnColor }"
>
<ArrowDownBold />
<loadingBg
v-if="props.btnLoading"
class="back-to-bottom-loading-svg-bg"
/>
</el-icon>
</slot>
</div>
</div>
</template>

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/BubbleList/style.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.el-bubble-list {
display: flex;
flex-direction: column;
flex-direction: column-reverse;
gap: 16px;
min-height: 0;
max-height: var(--el-bubble-list-max-height);
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/stories/BubbleList/CustomSolt.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,20 @@ console.log('Hello, world!');
avatar: isUser ? avatar1 : avatar2,
avatarSize: '32px'
};

bubbleItems.value.push(obj as MessageItem);
bubbleListRef.value.scrollToBottom();
ElMessage.success(`条数:${bubbleItems.value.length}`);

let num = 50;
const T = setInterval(() => {
if (num < 1) {
clearInterval(T);
}
bubbleItems.value[bubbleItems.value.length - 1].content +=
'欢迎使用 Element Plus X .';
num--;
}, 100);
Comment on lines +46 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential memory leak: Clear interval on component unmount.

The interval continues running even if the component unmounts, which could cause memory leaks and errors when trying to update unmounted components.

Store the interval ID at the component level and clear it on unmount:

+const messageIntervals = new Set<NodeJS.Timeout>();

 function addMessage() {
   // ... existing code ...
   
-  let num = 50;
+  let count = 50;
   const T = setInterval(() => {
-    if (num < 1) {
+    if (count < 1) {
       clearInterval(T);
+      messageIntervals.delete(T);
     }
     bubbleItems.value[bubbleItems.value.length - 1].content +=
       '欢迎使用 Element Plus X .';
-    num--;
+    count--;
   }, 100);
+  messageIntervals.add(T);
 }
+
+onUnmounted(() => {
+  messageIntervals.forEach(interval => clearInterval(interval));
+});

Also renamed the local variable from num to count to avoid shadowing the component-level num ref.

🤖 Prompt for AI Agents
In packages/core/src/stories/BubbleList/CustomSolt.vue around lines 46 to 54,
the setInterval is not cleared when the component unmounts, causing a potential
memory leak. To fix this, store the interval ID in a component-level variable
and use the component's unmount lifecycle hook to clear the interval. Also,
rename the local variable from num to count to avoid shadowing any
component-level num ref.

}

function handleOnComplete(_self: unknown) {
Expand Down