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