重构:抽出 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:
+92
@@ -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;
|
||||
}
|
||||
+114
@@ -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;
|
||||
}
|
||||
+89
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user