短历史一屏以内时收起悬空的「回到底部」胶囊
- useConversationScroll 新增 syncBottomFromLayout:按真实几何复核 isNearBottom,为真即恢复跟随、收起胶囊并清掉「有新回复」 - 布局补偿、turns 内容变化与 ResizeObserver/MutationObserver 回调都先走一次复核,内容塌回一屏以内不再依赖滚动事件 - 补回归测试:列表内容塌回一屏以内并触发布局变化,没有任何滚动事件也应收起胶囊 - ADR 第 3 节记录贴底判定不能只认 onScroll
This commit is contained in:
+31
@@ -356,6 +356,37 @@ describe('回到底部胶囊', () => {
|
||||
);
|
||||
expect(capsule()).toBe('回到底部');
|
||||
});
|
||||
|
||||
it('内容塌回一屏以内:没有滚动事件也收起胶囊', () => {
|
||||
const metrics: ListMetrics = {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 1200,
|
||||
clientHeight: 400,
|
||||
};
|
||||
stubListMetrics(metrics);
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
|
||||
const view = render(
|
||||
<Harness
|
||||
turns={[turn('t1')]}
|
||||
historyHasMore={false}
|
||||
historyLoading={false}
|
||||
historyError={null}
|
||||
onLoadEarlierHistory={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const capsule = () => view.getByTestId('capsule').textContent;
|
||||
|
||||
metrics.scrollTop = 0;
|
||||
fireEvent.scroll(view.getByTestId('list'));
|
||||
expect(capsule()).toBe('回到底部');
|
||||
|
||||
// 回合收口把过程折进收起的 `<details>`:内容塌回一屏以内,列表不会再发任何滚动事件。
|
||||
// 只认 onScroll 里那次判定的 atBottom 会把胶囊永远挂在一个滚不动的列表上。
|
||||
metrics.scrollHeight = 400;
|
||||
FakeResizeObserver.triggerLayoutChange();
|
||||
|
||||
expect(capsule()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('加载行', () => {
|
||||
|
||||
+34
-3
@@ -17,6 +17,7 @@ import {
|
||||
} from './conversationScrollAnchor';
|
||||
import {
|
||||
HISTORY_LOADING_INDICATOR_DELAY_MS,
|
||||
HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS,
|
||||
isNearBottom,
|
||||
readConversationListMetrics,
|
||||
scrollToBottomLabel,
|
||||
@@ -127,6 +128,7 @@ export function useConversationScroll({
|
||||
const showHistoryLoading = useDelayedFlag(
|
||||
historyLoading,
|
||||
HISTORY_LOADING_INDICATOR_DELAY_MS,
|
||||
HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -141,6 +143,26 @@ export function useConversationScroll({
|
||||
loadEarlierRef.current();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 按真实几何复核「在底部」,返回复核结果。
|
||||
*
|
||||
* `atBottom` 的唯一常规来源是 `onScroll`,但内容**变短**到一屏以内时不会有滚动事件:回合收口把
|
||||
* 过程折进收起的 `<details>`、历史加载行卸载,都会让 `scrollHeight` 掉回 `clientHeight` 以下,
|
||||
* 之后列表根本滚不动,可 `atBottom` 还停在离开底部时的假值——「回到底部」胶囊就永远挂在一个没有
|
||||
* 可滚余量的列表上。布局变化与内容变化都是复核点。
|
||||
*
|
||||
* 复核为真时同步恢复跟随并清掉「有新回复」:都已经滚无可滚了,留着未读提示只会是指向空处的入口。
|
||||
*/
|
||||
const syncBottomFromLayout = useCallback((): boolean => {
|
||||
const list = listRef.current;
|
||||
if (!list) return followLatestRef.current;
|
||||
if (!isNearBottom(readConversationListMetrics(list))) return false;
|
||||
followLatestRef.current = true;
|
||||
setAtBottom(true);
|
||||
setHasNewReply(false);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 布局变化后的补偿:跟随时贴底;否则优先冻结刚展开的折叠头,再退到前插锚点。
|
||||
*
|
||||
@@ -151,6 +173,9 @@ export function useConversationScroll({
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
|
||||
// 先按真实几何复核贴底:内容塌回一屏以内时不会再有任何滚动事件来翻转 `atBottom`。
|
||||
syncBottomFromLayout();
|
||||
|
||||
// 程序化滚动在飞:滚动位置归这次动画所有,直接写 `scrollTop` 会把动画取消在半路。
|
||||
// 但内容在这期间变高(流式正文、图片撑开)时,点击瞬间记下的目标已经不是底部了——动画会
|
||||
// 停在旧目标上,而 `programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是跟随与补偿
|
||||
@@ -187,7 +212,7 @@ export function useConversationScroll({
|
||||
// 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `<details>`、没有布局盒算不出偏移):
|
||||
// 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。
|
||||
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
|
||||
}, []);
|
||||
}, [syncBottomFromLayout]);
|
||||
|
||||
// 换会话:列表容器不重挂载,滚动所有权必须显式复位,否则上一个会话的「已离开底部」
|
||||
// 会漏到新会话。必须先于下面那条「内容变化」的 effect:终态指纹要先同步成新会话的内容,
|
||||
@@ -214,8 +239,11 @@ export function useConversationScroll({
|
||||
scrollListToBottom(listRef.current, 'auto');
|
||||
return;
|
||||
}
|
||||
// 回合收口会把过程折进收起的 `<details>`,内容可能就此塌回一屏以内:先按真实几何复核,
|
||||
// 已经滚无可滚就不再点亮「有新回复」(那时列表里根本没有可去的底部)。
|
||||
if (syncBottomFromLayout()) return;
|
||||
if (gainedTerminalContent) setHasNewReply(true);
|
||||
}, [turns]);
|
||||
}, [syncBottomFromLayout, turns]);
|
||||
|
||||
// 观察列表的直接子元素:展开 / 收起、流式正文增长、图片撑高、历史前插都只有这一个入口。
|
||||
// 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅,同时让锚点的块集合缓存失效。
|
||||
@@ -232,6 +260,9 @@ export function useConversationScroll({
|
||||
for (const child of Array.from(list.children)) {
|
||||
observer.observe(child);
|
||||
}
|
||||
// 子元素增删本身就可能让内容长度跨过视口(历史加载行挂卸、回合收口折叠),而这不会命中任何
|
||||
// 被观察子元素的高度变化:这里按真实几何补一次贴底复核,避免胶囊赖在滚不动的列表上。
|
||||
syncBottomFromLayout();
|
||||
};
|
||||
subscribe();
|
||||
const mutations = new MutationObserver(subscribe);
|
||||
@@ -241,7 +272,7 @@ export function useConversationScroll({
|
||||
mutations.disconnect();
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [compensateLayout]);
|
||||
}, [compensateLayout, syncBottomFromLayout]);
|
||||
|
||||
// 程序化滚动被用户接手(滚轮 / 触摸 / 键盘)时立刻交还控制权。
|
||||
// 动画被打断后浏览器不会再发贴底滚动事件,标记不交还就会永远挂着,后续跟随与补偿全失效。
|
||||
|
||||
@@ -40,6 +40,7 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
|
||||
|
||||
- 列表底部居中的悬浮胶囊(`sticky`),跟着滚动容器走、不随内容滚走。
|
||||
- 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。
|
||||
- 贴底判定不能只认 `onScroll`:内容**变短**到一屏以内时不会再有任何滚动事件(回合收口把过程折进收起的 `<details>`、历史加载行卸载),`atBottom` 会停在离开底部时的假值,胶囊就永远挂在一个滚不动的列表上(短历史一屏显示完却还悬着「回到底部」)。因此每次布局变化(`ResizeObserver`)、子元素增删(`MutationObserver`)与内容变化后都按真实几何复核一次 `isNearBottom`,复核为真即收起胶囊并清掉「有新回复」。
|
||||
- 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。
|
||||
- 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。
|
||||
- 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。
|
||||
|
||||
Reference in New Issue
Block a user