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
250 changes: 131 additions & 119 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,89 @@ 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);
// 检查是否视图中的元素
const checkViewRef = ref<HTMLElement | null>(null);

// 监听数组长度变化,如果改变,则判断是否在最底部,如果在,就自动滚动到底部
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;
stopAutoScrollToBottom.value = false;
autoScroll();
}
// 父组件触发滚动到指定气泡框
function scrollToBubble(index: number) {
const container = scrollContainer.value;
if (!container) return;

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

if (index >= bubbles.length) return;

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 intersectionObserver!: IntersectionObserver;
// 组件内部触发方法,跟随打字器滚动,滚动底部
function autoScroll() {
if (scrollContainer.value) {
const listBubbles = scrollContainer.value!.querySelectorAll(
'.el-bubble-content-wrapper'
);
// 如果页面上有监听节点,先移除
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
const lastItem = listBubbles[listBubbles.length - 1];
if (lastItem) {
resizeObserver.value = new ResizeObserver(() => {
if (!stopAutoScrollToBottom.value) {
scrollToBottom();
const container = checkViewRef.value;
if (!container) return;
if (intersectionObserver) {
intersectionObserver.unobserve(container);
}
container.scrollIntoView({
behavior: 'smooth'
});
intersectionObserver = new IntersectionObserver(
entries => {
if (stopAutoScrollToBottom.value)
return intersectionObserver.unobserve(container);
entries.forEach(entry => {
if (entry.isIntersecting) {
intersectionObserver.unobserve(container);
} else {
intersectionObserver.unobserve(container);
intersectionObserver.disconnect();
autoScroll();
}
});
resizeObserver.value.observe(lastItem);
},
{
threshold: 1
}
}
);
intersectionObserver.observe(container);
}
Comment on lines +115 to 145
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 with IntersectionObserver.

The global intersectionObserver variable could lead to memory leaks if multiple observers are created without proper cleanup.

Consider this improvement:

-let intersectionObserver!: IntersectionObserver;
 // 组件内部触发方法,跟随打字器滚动,滚动底部
 function autoScroll() {
   const container = checkViewRef.value;
   if (!container) return;
-  if (intersectionObserver) {
-    intersectionObserver.unobserve(container);
-  }
   container.scrollIntoView({
     behavior: 'smooth'
   });
-  intersectionObserver = new IntersectionObserver(
+  const observer = new IntersectionObserver(
     entries => {
       if (stopAutoScrollToBottom.value)
-        return intersectionObserver.unobserve(container);
+        return observer.disconnect();
       entries.forEach(entry => {
         if (entry.isIntersecting) {
-          intersectionObserver.unobserve(container);
+          observer.disconnect();
         } else {
-          intersectionObserver.unobserve(container);
-          intersectionObserver.disconnect();
+          observer.disconnect();
           autoScroll();
         }
       });
     },
     {
       threshold: 1
     }
   );
-  intersectionObserver.observe(container);
+  observer.observe(container);
 }

Also consider adding a cleanup in onBeforeUnmount to ensure any active observer is disconnected.

📝 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 intersectionObserver!: IntersectionObserver;
// 组件内部触发方法,跟随打字器滚动,滚动底部
function autoScroll() {
if (scrollContainer.value) {
const listBubbles = scrollContainer.value!.querySelectorAll(
'.el-bubble-content-wrapper'
);
// 如果页面上有监听节点,先移除
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
const lastItem = listBubbles[listBubbles.length - 1];
if (lastItem) {
resizeObserver.value = new ResizeObserver(() => {
if (!stopAutoScrollToBottom.value) {
scrollToBottom();
const container = checkViewRef.value;
if (!container) return;
if (intersectionObserver) {
intersectionObserver.unobserve(container);
}
container.scrollIntoView({
behavior: 'smooth'
});
intersectionObserver = new IntersectionObserver(
entries => {
if (stopAutoScrollToBottom.value)
return intersectionObserver.unobserve(container);
entries.forEach(entry => {
if (entry.isIntersecting) {
intersectionObserver.unobserve(container);
} else {
intersectionObserver.unobserve(container);
intersectionObserver.disconnect();
autoScroll();
}
});
resizeObserver.value.observe(lastItem);
},
{
threshold: 1
}
}
);
intersectionObserver.observe(container);
}
// 组件内部触发方法,跟随打字器滚动,滚动底部
function autoScroll() {
const container = checkViewRef.value;
if (!container) return;
container.scrollIntoView({
behavior: 'smooth'
});
const observer = new IntersectionObserver(
entries => {
if (stopAutoScrollToBottom.value)
return observer.disconnect();
entries.forEach(entry => {
if (entry.isIntersecting) {
observer.disconnect();
} else {
observer.disconnect();
autoScroll();
}
});
},
{
threshold: 1
}
);
observer.observe(container);
}
🧰 Tools
🪛 ESLint

[error] 119-119: Expect newline after if

(antfu/if-newline)


[error] 133-133: 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 115 to 145,
the global intersectionObserver variable may cause memory leaks by creating
multiple observers without proper cleanup. To fix this, ensure that before
assigning a new IntersectionObserver to the variable, any existing observer is
disconnected and unobserved. Additionally, add a cleanup step in the component's
onBeforeUnmount lifecycle hook to disconnect the observer if it exists,
preventing lingering observers after the component is destroyed.


const completeMap = ref<Record<number, TypewriterInstance>>({});
Expand All @@ -146,6 +152,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 +174,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;

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

// 判断是否距离底部小于阈值 (这里吸附值大一些会体验更好)
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 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;
}
} 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 +240,38 @@ defineExpose({
:class="{ 'always-scrollbar': props.alwaysShowScrollbar }"
@scroll="handleScroll"
>
<div ref="checkViewRef" style="height: 1px" />
<!-- 自定义按钮插槽 默认返回按钮 -->
<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 +308,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.

🛠️ Refactor suggestion

⚠️ Potential issue

Variable shadowing and potential performance issues.

The local variable num shadows the ref declared at line 17. Additionally, updating the message content 50 times in 5 seconds could cause performance issues.

Consider these improvements:

-  let num = 50;
-  const T = setInterval(() => {
-    if (num < 1) {
-      clearInterval(T);
+  let counter = 50;
+  const intervalId = setInterval(() => {
+    if (counter < 1 || bubbleItems.value.length === 0) {
+      clearInterval(intervalId);
+      return;
     }
     bubbleItems.value[bubbleItems.value.length - 1].content +=
       '欢迎使用 Element Plus X .';
-    num--;
+    counter--;
   }, 100);

This addresses variable shadowing, follows naming conventions, and adds a safety check for empty arrays.

📝 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 num = 50;
const T = setInterval(() => {
if (num < 1) {
clearInterval(T);
}
bubbleItems.value[bubbleItems.value.length - 1].content +=
'欢迎使用 Element Plus X .';
num--;
}, 100);
let counter = 50;
const intervalId = setInterval(() => {
if (counter < 1 || bubbleItems.value.length === 0) {
clearInterval(intervalId);
return;
}
bubbleItems.value[bubbleItems.value.length - 1].content +=
'欢迎使用 Element Plus X .';
counter--;
}, 100);
🤖 Prompt for AI Agents
In packages/core/src/stories/BubbleList/CustomSolt.vue around lines 46 to 54,
the local variable 'num' shadows a ref declared earlier, which can cause
confusion and bugs. Rename the local variable to avoid shadowing, follow
consistent naming conventions, and add a check to ensure 'bubbleItems.value' is
not empty before updating its last item's content to prevent runtime errors.
Also, consider reducing the update frequency or batching updates to mitigate
potential performance issues from frequent DOM updates.

}

function handleOnComplete(_self: unknown) {
Expand Down