重构:抽出 DirectProject 对话滚动判据、锚点与折叠补偿纯函数

新增 conversationScrollPolicy.ts:滚动/自动加载阈值、加载门、胶囊文案与填充视口判据集中在一处。
新增 conversationScrollAnchor.ts:回合 key + 块序号 + 偏移的锚点读取与还原,供前插补偿使用。
新增 conversationToggleReveal.ts:折叠头解析、头部冻结与超长正文对齐,覆盖 details 与 aria-expanded 两种折叠。
新增 useDelayedFlag.ts:延迟显示开关位,避免本地读取瞬间返回时闪一下加载行。

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 17:41:55 +08:00
parent c68531b0aa
commit f0ae0bd5d7
4 changed files with 317 additions and 0 deletions
@@ -0,0 +1,92 @@
/**
* 更早历史前插时的视口锚点:读取与还原。
*
* 锚点粒度是「回合 key(`data-turn-key`)+ 该回合内的块序号 + 相对列表顶边的像素偏移」:
* 合并更早历史只会把新回合插在更早的位置,已有回合的 key、块序号和块内结构都不变,所以这个
* 锚点跨前插稳定。还原只改 `scrollTop`,不做任何 DOM 变更。
*
* 为什么不用浏览器原生 scroll anchoring:原生补偿只能让"当前可见内容"不动,做不到「跟随时
* 展开要贴底」,两套补偿同时生效还会互相抵消;列表因此显式关闭原生锚定,补偿只走这一份。
*/
export type ConversationTurnAnchor = {
/** 回合身份:`DirectChatTurn.key`。 */
key: string;
/** 同一个回合里的第几个块(`data-turn-key` 相同的节点按文档顺序编号)。 */
index: number;
/** 该块顶边相对列表顶边的偏移,可为负。 */
offset: number;
};
const TURN_BLOCK_SELECTOR = '[data-turn-key]';
function turnKeyOf(element: Element): string | null {
return element instanceof HTMLElement
? (element.dataset.turnKey ?? null)
: null;
}
/** 列表里所有带回合身份的块,按文档顺序。 */
export function collectTurnBlocks(list: HTMLElement): HTMLElement[] {
return Array.from(list.querySelectorAll<HTMLElement>(TURN_BLOCK_SELECTOR));
}
/** 按「回合 key + 块序号」定位具体块;找不到返回 null。 */
export function findTurnBlock(
list: HTMLElement,
key: string,
index: number,
): HTMLElement | null {
const blocks = collectTurnBlocks(list).filter(
(block) => turnKeyOf(block) === key,
);
return blocks[index] ?? null;
}
/**
* 顶部第一条真正露出视口的块。
*
* 用 `bottom > 列表顶边` 而不是 `top >= 列表顶边`:滚到中间时最上面那条通常只露出一半,
* 它才是"用户正在读的位置",拿下一整条当锚点会在前插时把半条消息顶出去。
*/
export function readTopVisibleTurnAnchor(
list: HTMLElement,
): ConversationTurnAnchor | null {
const listTop = list.getBoundingClientRect().top;
const blocks = collectTurnBlocks(list);
for (let position = 0; position < blocks.length; position += 1) {
const block = blocks[position]!;
if (block.getBoundingClientRect().bottom - listTop <= 0) continue;
const key = turnKeyOf(block);
if (!key) continue;
let index = 0;
for (let earlier = 0; earlier < position; earlier += 1) {
if (turnKeyOf(blocks[earlier]!) === key) index += 1;
}
return { key, index, offset: block.getBoundingClientRect().top - listTop };
}
return null;
}
/**
* 按锚点还原 `scrollTop`,让同一个块回到原来的偏移。
*
* 返回是否还原成功:锚点对应的块已经不在列表里时返回 false,调用方可以据此放弃这次补偿,
* 而不是拿着过期偏移去改 `scrollTop`。
*/
export function restoreTurnAnchor(
list: HTMLElement,
anchor: ConversationTurnAnchor,
): boolean {
const block = findTurnBlock(list, anchor.key, anchor.index);
if (!block) return false;
const listTop = list.getBoundingClientRect().top;
list.scrollTop += block.getBoundingClientRect().top - listTop - anchor.offset;
return true;
}
@@ -0,0 +1,114 @@
/**
* DirectProject 会话滚动与更早历史自动加载的判据(常量 + 纯函数)。
*
* 这一层只有数字与文案,不碰 DOM:阈值、触发条件、胶囊文案都在这里定死,
* `useConversationScroll` 只负责把真实几何喂进来。契约与取舍见
* `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`。
*/
/**
* 距列表顶边多少像素以内算「触顶」,触发更早历史加载。
*
* 必须小于 {@link DIRECT_SCROLL_BOTTOM_THRESHOLD}:两个阈值如果相等,刚往下滚一点就同时
* 被判成「触顶」和「在底部」,历史加载与跟随最新会互相打架。
*/
export const DIRECT_HISTORY_TOP_THRESHOLD = 24;
/** 距底边多少像素以内算「在底部」:决定是否跟随最新,以及回到底部胶囊的显隐。 */
export const DIRECT_SCROLL_BOTTOM_THRESHOLD = 48;
/** 加载行延迟多少毫秒才显示:本地读取常常瞬间返回,立即显示会闪一下。 */
export const HISTORY_LOADING_INDICATOR_DELAY_MS = 150;
export const HISTORY_LOADING_TEXT = '正在加载更早的对话';
export const HISTORY_ERROR_TEXT = '加载更早对话失败';
export const HISTORY_RETRY_TEXT = '重试';
export const SCROLL_TO_BOTTOM_TEXT = '回到底部';
export const SCROLL_TO_BOTTOM_UNREAD_TEXT = '有新回复 · 回到底部';
/** 滚动容器当前几何;三个值都取真实布局值,不做任何补偿。 */
export type ConversationListMetrics = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
};
export function readConversationListMetrics(element: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}): ConversationListMetrics {
return {
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
};
}
/**
* 距底部还有多少像素。
*
* 内容比视口短时按 0 处理:此时「距底部」是负数,直接比较会让 `isNearBottom` 恒真以外的
* 阈值判断出现反直觉结果。
*/
export function distanceFromBottom(metrics: ConversationListMetrics): number {
return Math.max(
0,
metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight,
);
}
export function isNearBottom(metrics: ConversationListMetrics): boolean {
return distanceFromBottom(metrics) <= DIRECT_SCROLL_BOTTOM_THRESHOLD;
}
export function isNearHistoryTop(metrics: ConversationListMetrics): boolean {
return metrics.scrollTop <= DIRECT_HISTORY_TOP_THRESHOLD;
}
/**
* 内容还没填满视口。
*
* 「显示更早的对话」按钮删除后,短历史只能靠这条判据继续补:只要还有 `hasMore`,就一直加载到
* 填满视口或加载器说没有更早历史为止,否则用户看到的就是一块拉不动的静止画面。
*/
export function needsViewportFill(metrics: ConversationListMetrics): boolean {
return metrics.scrollHeight <= metrics.clientHeight;
}
/** 自动加载更早历史的前置门:三条都成立才允许发起。 */
export type ConversationHistoryLoadGate = {
historyHasMore: boolean;
historyLoading: boolean;
historyError: string | null;
};
function canStartHistoryLoad(gate: ConversationHistoryLoadGate): boolean {
return gate.historyHasMore && !gate.historyLoading && !gate.historyError;
}
/**
* 滚动触顶时的自动加载判据。
*
* 失败后挂起:`historyError` 非空期间不再自动触发,只有手动重试(清掉错误)或切换项目才会
* 重新开始——弱网下反复重试既看不到反馈,也会持续打接口。
*/
export function shouldLoadEarlierOnScroll(
metrics: ConversationListMetrics,
gate: ConversationHistoryLoadGate,
): boolean {
return canStartHistoryLoad(gate) && isNearHistoryTop(metrics);
}
/** 首帧之后填充视口的自动加载判据,与滚动触发共用同一道门。 */
export function shouldFillViewport(
metrics: ConversationListMetrics,
gate: ConversationHistoryLoadGate,
): boolean {
return canStartHistoryLoad(gate) && needsViewportFill(metrics);
}
/** 回到底部胶囊的文案:不跟随时又来了新内容才提示「有新回复」。 */
export function scrollToBottomLabel(hasNewReply: boolean): string {
return hasNewReply ? SCROLL_TO_BOTTOM_UNREAD_TEXT : SCROLL_TO_BOTTOM_TEXT;
}
@@ -0,0 +1,89 @@
/**
* 折叠块展开 / 收起后的视口处理。
*
* 展开一个 `<details>` 或 `[aria-expanded]` 块不会改变 `scrollTop`,但会把正文长在视口下方:
* 用户必须再滚一次才能看到刚展开的内容。这里的做法是:
* 1. 头部仍在视口里 → 把它拉回展开前的屏幕位置(折叠头冻结);
* 2. 展开正文比视口还高、头部已经锚不住 → 对齐正文顶边,让用户从正文开头读起。
*
* 判断所需的"展开前位置"由调用方在点击捕获阶段记录,见 `useConversationScroll`。
*/
export type ConversationToggleFold = {
/** 触发折叠的头部元素:`<details>` 的 `summary`,或带 `aria-expanded` 的按钮。 */
head: HTMLElement;
/** 头部在展开前相对视口顶边的位置(`getBoundingClientRect().top`)。 */
headTop: number;
};
/**
* 从点击目标向上找折叠头。
*
* 两种折叠头都要认:`DirectProjectTurn` 的「执行过程」是 `<details>`,`ToolCallGroup` 与
* 「思考过程」是带 `aria-expanded` 的按钮。
*/
export function resolveToggleHead(target: Element | null): HTMLElement | null {
if (!target) return null;
const summary = target.closest('summary');
if (summary instanceof HTMLElement) return summary;
const expandable = target.closest('[aria-expanded]');
if (expandable instanceof HTMLElement) return expandable;
return null;
}
/** 折叠头对应的正文元素;解析不出来返回 null(此时只做头部冻结)。 */
export function resolveToggleBody(head: HTMLElement): HTMLElement | null {
if (head.tagName === 'SUMMARY') {
const details = head.closest('details');
if (!details) return null;
const body = Array.from(details.children).find(
(child) => child.tagName !== 'SUMMARY',
);
return body instanceof HTMLElement ? body : null;
}
const controls = head.getAttribute('aria-controls');
if (!controls) return null;
return head.ownerDocument.getElementById(controls);
}
/**
* 布局变化后把折叠头拉回展开前的位置;返回是否做了补偿。
*
* 头部已经不在视口里时返回 false 并保持原状:继续按旧位置冻结会让画面跳到用户没在看的地方,
* 这时应该退回到「还原顶部可见回合锚点」。
*/
export function freezeToggleHead(
list: HTMLElement,
fold: ConversationToggleFold,
): boolean {
if (!fold.head.isConnected) return false;
const listRect = list.getBoundingClientRect();
const headRect = fold.head.getBoundingClientRect();
const headVisible =
headRect.bottom > listRect.top && headRect.top < listRect.bottom;
if (!headVisible) return false;
list.scrollTop += headRect.top - fold.headTop;
revealExpandedBodyTop(list, fold.head);
return true;
}
/**
* 展开正文高于视口且底部超出视口时,把正文顶边对齐到视口顶边。
*
* 正文能在视口里放得下就不介入:那种情况下头部冻结已经足够,再动一次会把刚冻住的头部推走。
*/
function revealExpandedBodyTop(list: HTMLElement, head: HTMLElement): void {
const body = resolveToggleBody(head);
if (!body) return;
const listRect = list.getBoundingClientRect();
const bodyRect = body.getBoundingClientRect();
const viewportHeight = listRect.bottom - listRect.top;
if (bodyRect.height <= viewportHeight) return;
if (bodyRect.bottom <= listRect.bottom) return;
list.scrollTop += bodyRect.top - listRect.top;
}
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
/**
* 延迟显示一个开关位:为真后等 `delayMs` 才转成可见,转假立即隐藏。
*
* 用来避免「本地读取瞬间返回」时闪一下加载行:反馈要等得起,但不能一闪而过。
*/
export function useDelayedFlag(value: boolean, delayMs: number): boolean {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!value) {
setVisible(false);
return undefined;
}
const timer = setTimeout(() => setVisible(true), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return visible;
}