fix: 修复聊天区滚动锁定与手动上滚脱锁
This commit is contained in:
parent
ed5315eef6
commit
591a5e64cc
@ -106,6 +106,9 @@ export const conversationMethods = {
|
||||
|
||||
debugLog('前端状态重置完成');
|
||||
this._scrollListenerReady = false;
|
||||
this._manualScrollSuppressUntil = 0;
|
||||
this._escapedByUserScroll = false;
|
||||
this._autoRelockCooldownUntil = 0;
|
||||
this.$nextTick(() => {
|
||||
this.ensureScrollListener();
|
||||
});
|
||||
|
||||
@ -112,23 +112,52 @@ export const uiMethods = {
|
||||
|
||||
handleStickStateChange(payload) {
|
||||
const escaped = !!payload?.escapedFromLock;
|
||||
const isAtBottom = !!payload?.isAtBottom;
|
||||
const isNearBottom = !!payload?.isNearBottom;
|
||||
const nearBottom = isAtBottom || isNearBottom;
|
||||
const userEscaped = !!this._escapedByUserScroll;
|
||||
uiBounceTrace(
|
||||
'stick-state-change',
|
||||
{
|
||||
isAtBottom: !!payload?.isAtBottom,
|
||||
isNearBottom: !!payload?.isNearBottom,
|
||||
isAtBottom,
|
||||
isNearBottom,
|
||||
escapedFromLock: escaped,
|
||||
userEscaped,
|
||||
autoScrollEnabled: this.autoScrollEnabled,
|
||||
userScrolling: this.userScrolling
|
||||
},
|
||||
'stick-state-change',
|
||||
180
|
||||
);
|
||||
this.stickIsAtBottom = !!payload?.isAtBottom;
|
||||
this.stickIsNearBottom = !!payload?.isNearBottom;
|
||||
this.chatSetScrollState({
|
||||
userScrolling: escaped
|
||||
});
|
||||
this.stickIsAtBottom = isAtBottom;
|
||||
this.stickIsNearBottom = isNearBottom;
|
||||
|
||||
// 用户主动脱离锁定:只要没回到底部/近底部,就保持“手动滚动中”状态
|
||||
if (userEscaped) {
|
||||
if (nearBottom) {
|
||||
this._escapedByUserScroll = false;
|
||||
this.chatSetScrollState({ userScrolling: false });
|
||||
} else {
|
||||
this.chatSetScrollState({ userScrolling: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 非用户触发的 escaped(例如高度突增导致的短暂锚点漂移)不应解除锁定
|
||||
this.chatSetScrollState({ userScrolling: false });
|
||||
if (!escaped) {
|
||||
return;
|
||||
}
|
||||
const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true;
|
||||
if (!this.autoScrollEnabled || !active) {
|
||||
return;
|
||||
}
|
||||
const chatArea = this.getChatAreaController();
|
||||
if (chatArea && typeof chatArea.conditionalStickToBottom === 'function') {
|
||||
chatArea.conditionalStickToBottom({ force: false });
|
||||
return;
|
||||
}
|
||||
conditionalScrollToBottomHelper(this);
|
||||
},
|
||||
|
||||
handleUserScrollIntent(payload) {
|
||||
@ -139,6 +168,8 @@ export const uiMethods = {
|
||||
}
|
||||
// 用户手动滚动后,短时间内禁止任何自动追底,规避 escapedFromLock 状态抖动竞态
|
||||
this._manualScrollSuppressUntil = Math.max(this._manualScrollSuppressUntil || 0, ts + 1600);
|
||||
this._escapedByUserScroll = true;
|
||||
this.chatSetScrollState({ userScrolling: true });
|
||||
uiBounceTrace(
|
||||
'ui.user-scroll-intent',
|
||||
{
|
||||
@ -1322,6 +1353,7 @@ export const uiMethods = {
|
||||
'ui.scrollToBottom:called',
|
||||
80
|
||||
);
|
||||
this._escapedByUserScroll = false;
|
||||
const chatArea = this.getChatAreaController();
|
||||
if (chatArea && typeof chatArea.scrollToBottom === 'function') {
|
||||
chatArea.scrollToBottom({
|
||||
@ -1367,12 +1399,14 @@ export const uiMethods = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 以 stick 引擎的 escapedFromLock 为最高优先级,避免状态同步竞态导致误追底
|
||||
if (stickState?.escapedFromLock) {
|
||||
// 仅在“确认是用户主动脱离锁定”时才阻断自动追底;
|
||||
// 对于内容高度突变导致的 escapedFromLock,不应长期卡住锁定。
|
||||
if (stickState?.escapedFromLock && this._escapedByUserScroll) {
|
||||
uiBounceTrace(
|
||||
'ui.conditionalScrollToBottom:skip-escaped-lock',
|
||||
{
|
||||
escapedFromLock: !!stickState?.escapedFromLock,
|
||||
escapedByUser: !!this._escapedByUserScroll,
|
||||
isNearBottom: !!stickState?.isNearBottom,
|
||||
isAtBottom: !!stickState?.isAtBottom
|
||||
},
|
||||
@ -1424,6 +1458,7 @@ export const uiMethods = {
|
||||
force: true
|
||||
});
|
||||
}
|
||||
this._escapedByUserScroll = false;
|
||||
this.chatSetScrollState({ autoScrollEnabled: true, userScrolling: false });
|
||||
return true;
|
||||
},
|
||||
|
||||
@ -155,6 +155,8 @@ export function dataState() {
|
||||
_boundDragLeave: null,
|
||||
_boundDrop: null,
|
||||
_manualScrollSuppressUntil: 0,
|
||||
_escapedByUserScroll: false,
|
||||
_autoRelockCooldownUntil: 0,
|
||||
|
||||
// stick-to-bottom 状态(用于“回到底部”按钮显隐)
|
||||
stickIsAtBottom: true,
|
||||
|
||||
@ -733,11 +733,14 @@ let bounceTraceCount = 0;
|
||||
const BOUNCE_TRACE_MAX = 240;
|
||||
const bounceTraceLastTsByKey = new Map<string, number>();
|
||||
let lastObservedTop = 0;
|
||||
let lastObservedHeight = 0;
|
||||
let lastUserDownScrollTs = 0;
|
||||
let lastUserWheelUpTs = 0;
|
||||
let lastProgrammaticHintTs = 0;
|
||||
let lastProgrammaticHintSource = '';
|
||||
let suppressUserIntentUntil = 0;
|
||||
let scrollListener: ((event: Event) => void) | null = null;
|
||||
let wheelListener: ((event: WheelEvent) => void) | null = null;
|
||||
let traceAttachLogged = false;
|
||||
|
||||
function isScrollBounceTraceEnabled() {
|
||||
@ -764,7 +767,9 @@ function getScrollMetrics(el: HTMLElement) {
|
||||
height,
|
||||
client,
|
||||
remain: height - top - client,
|
||||
hasShowHtml: !!el.querySelector('show_html, show-html, .chat-inline-html, .chat-inline-card--html')
|
||||
hasShowHtml: !!el.querySelector(
|
||||
'show_html, show-html, .chat-inline-html, .chat-inline-card--html'
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@ -798,7 +803,11 @@ function detachBounceListener() {
|
||||
if (el && scrollListener) {
|
||||
el.removeEventListener('scroll', scrollListener as EventListener);
|
||||
}
|
||||
if (el && wheelListener) {
|
||||
el.removeEventListener('wheel', wheelListener as EventListener, true);
|
||||
}
|
||||
scrollListener = null;
|
||||
wheelListener = null;
|
||||
}
|
||||
|
||||
function attachBounceListener() {
|
||||
@ -807,16 +816,57 @@ function attachBounceListener() {
|
||||
if (scrollListener) {
|
||||
el.removeEventListener('scroll', scrollListener as EventListener);
|
||||
}
|
||||
if (wheelListener) {
|
||||
el.removeEventListener('wheel', wheelListener as EventListener, true);
|
||||
}
|
||||
lastObservedTop = el.scrollTop || 0;
|
||||
lastObservedHeight = el.scrollHeight || 0;
|
||||
wheelListener = (event: WheelEvent) => {
|
||||
const target = scrollRef.value;
|
||||
if (!target) return;
|
||||
const trusted = (event as any).isTrusted === true;
|
||||
const deltaY = Number(event.deltaY || 0);
|
||||
if (!(trusted && deltaY < -1 && Date.now() > suppressUserIntentUntil)) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
lastUserWheelUpTs = now;
|
||||
stopScroll();
|
||||
emit('user-scroll-intent', {
|
||||
ts: now,
|
||||
delta: deltaY,
|
||||
top: target.scrollTop || 0
|
||||
});
|
||||
bounceTraceLog(
|
||||
'user-scroll-intent:wheel-up',
|
||||
{ deltaY, top: target.scrollTop || 0, ...getScrollMetrics(target) },
|
||||
'user-scroll-intent:wheel-up',
|
||||
80
|
||||
);
|
||||
};
|
||||
el.addEventListener('wheel', wheelListener, { passive: true, capture: true });
|
||||
scrollListener = (event: Event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const top = target.scrollTop;
|
||||
const delta = top - lastObservedTop;
|
||||
const height = target.scrollHeight || 0;
|
||||
const heightDelta = height - lastObservedHeight;
|
||||
lastObservedTop = top;
|
||||
lastObservedHeight = height;
|
||||
const now = Date.now();
|
||||
const trusted = (event as any).isTrusted === true;
|
||||
if (trusted && delta > 5 && now > suppressUserIntentUntil) {
|
||||
const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140;
|
||||
const likelyLayoutDriven = Math.abs(heightDelta) > 2;
|
||||
const manualUpByWheel = delta < -3 && now - lastUserWheelUpTs <= 240;
|
||||
const strongManualUp = delta < -18 && now > suppressUserIntentUntil && !closeToProgrammaticHint;
|
||||
const shouldTreatAsUserIntent =
|
||||
trusted &&
|
||||
Math.abs(delta) > 5 &&
|
||||
now > suppressUserIntentUntil &&
|
||||
(!closeToProgrammaticHint || strongManualUp) &&
|
||||
(delta < 0 ? strongManualUp || manualUpByWheel || !likelyLayoutDriven : !likelyLayoutDriven);
|
||||
if (shouldTreatAsUserIntent) {
|
||||
// 用户手动滚动时,立即中断 stick 引擎中可能仍在运行的动画滚动
|
||||
stopScroll();
|
||||
lastUserDownScrollTs = now;
|
||||
@ -827,7 +877,16 @@ function attachBounceListener() {
|
||||
});
|
||||
bounceTraceLog(
|
||||
'user-scroll-intent',
|
||||
{ delta, top, ts: lastUserDownScrollTs, ...getScrollMetrics(target) },
|
||||
{
|
||||
delta,
|
||||
top,
|
||||
ts: lastUserDownScrollTs,
|
||||
closeToProgrammaticHint,
|
||||
likelyLayoutDriven,
|
||||
manualUpByWheel,
|
||||
strongManualUp,
|
||||
...getScrollMetrics(target)
|
||||
},
|
||||
'user-scroll-intent',
|
||||
120
|
||||
);
|
||||
@ -974,7 +1033,9 @@ async function stickConditionalScrollToBottom(options: { force?: boolean } = {})
|
||||
if (options.force) {
|
||||
return await stickScrollToBottom({ force: true, preserveScrollPosition: false });
|
||||
}
|
||||
return await stickScrollToBottom({ preserveScrollPosition: true });
|
||||
// conditional 场景由上层先判断“是否需要追底”,这里不要再使用 preserveScrollPosition,
|
||||
// 否则一旦高度突增导致 escapedFromLock=true,后续会被卡在非底部。
|
||||
return await stickScrollToBottom({ preserveScrollPosition: false });
|
||||
}
|
||||
|
||||
function getStickState() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user