修复:展开折叠块时把新展开的正文露出来

- 新增纯函数 toggleRevealDelta:折叠头冻结与露出正文合成一次 scrollTop 位移
- 正文底边超出视口只补超出量,正文比视口还高才对齐正文顶边,收起不额外滚动
- 补齐用例:纯函数六例与 freezeToggleHead 两条真机行为

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 18:18:26 +08:00
parent e82070098c
commit ecb84f1e70
2 changed files with 134 additions and 26 deletions
@@ -5,6 +5,7 @@ import {
freezeToggleHead,
resolveToggleBody,
resolveToggleHead,
toggleRevealDelta,
} from './conversationToggleReveal';
type StubRect = { top: number; bottom: number };
@@ -87,6 +88,77 @@ describe('resolveToggleBody', () => {
});
});
describe('toggleRevealDelta', () => {
const listRect = { top: 0, bottom: 400 };
it('头部没动、正文完整放得下:不滚动', () => {
expect(
toggleRevealDelta({
listRect,
headRect: { top: 100, bottom: 120 },
headTop: 100,
bodyRect: { top: 120, bottom: 300, height: 180 },
}),
).toBe(0);
});
it('正文比视口矮但底边超出视口:只滚到刚好露出底边', () => {
// 头部在视口下缘,刚展开的 200px 正文全部长在视口外:只补这 120px 的超出量。
expect(
toggleRevealDelta({
listRect,
headRect: { top: 300, bottom: 320 },
headTop: 300,
bodyRect: { top: 320, bottom: 520, height: 200 },
}),
).toBe(120);
});
it('正文比视口还高且底边超出:对齐正文顶边,让用户从正文开头读起', () => {
expect(
toggleRevealDelta({
listRect,
headRect: { top: 300, bottom: 320 },
headTop: 300,
bodyRect: { top: 320, bottom: 900, height: 580 },
}),
).toBe(320);
});
it('收起(正文高度归零)不额外滚动,只按头部位移补偿', () => {
expect(
toggleRevealDelta({
listRect,
headRect: { top: 10, bottom: 30 },
headTop: -40,
bodyRect: { top: 0, bottom: 0, height: 0 },
}),
).toBe(50);
});
it('上方内容把头部顶下去后正文已露在视口里:不再往回滚', () => {
expect(
toggleRevealDelta({
listRect,
headRect: { top: 10, bottom: 30 },
headTop: -40,
bodyRect: { top: 40, bottom: 600, height: 560 },
}),
).toBe(50);
});
it('解析不出正文:只按头部位移补偿', () => {
expect(
toggleRevealDelta({
listRect,
headRect: { top: 10, bottom: 30 },
headTop: 10,
bodyRect: null,
}),
).toBe(0);
});
});
describe('freezeToggleHead', () => {
it('头部仍在视口里:按位移把头部拉回展开前的屏幕位置', () => {
const { list, turnHead } = buildFixture();
@@ -97,15 +169,24 @@ describe('freezeToggleHead', () => {
expect(list.scrollTop).toBe(50);
});
it('展开正文比视口还高:再把正文顶边对齐到视口顶边', () => {
it('展开正文比视口矮但底边超出视口:滚到刚好露出正文底边', () => {
const { list, turnHead, turnBody } = buildFixture();
stubRect(turnHead, { top: 10, bottom: 30 });
stubRect(turnBody, { top: 40, bottom: 600 });
stubRect(turnHead, { top: 300, bottom: 320 });
stubRect(turnBody, { top: 320, bottom: 520 });
list.scrollTop = 0;
expect(freezeToggleHead(list, { head: turnHead, headTop: -40 })).toBe(true);
// 头部冻结 50px 之后,正文仍比视口高且底部超出,再对齐正文顶边(+40)。
expect(list.scrollTop).toBe(90);
expect(freezeToggleHead(list, { head: turnHead, headTop: 300 })).toBe(true);
expect(list.scrollTop).toBe(120);
});
it('展开正文比视口还高且底边超出:对齐正文顶边', () => {
const { list, turnHead, turnBody } = buildFixture();
stubRect(turnHead, { top: 300, bottom: 320 });
stubRect(turnBody, { top: 320, bottom: 900 });
list.scrollTop = 0;
expect(freezeToggleHead(list, { head: turnHead, headTop: 300 })).toBe(true);
expect(list.scrollTop).toBe(320);
});
it('头部已经不在视口里:不补偿,交给锚点还原', () => {
@@ -47,6 +47,46 @@ export function resolveToggleBody(head: HTMLElement): HTMLElement | null {
return head.ownerDocument.getElementById(controls);
}
/**
* 展开 / 收起一个折叠块需要的总位移。纯函数:所有几何由调用方测量后传进来。
*
* 两段合成一次位移,不能分两次写 `scrollTop`:
* 1. 折叠头冻结——把它拉回展开前的屏幕位置(`headTop` 是点击捕获阶段记下的);
* 2. 露出新展开的正文——正文底边在视口下方就补上超出量;正文比视口还高就对正文顶边,
* 让用户从正文开头读起。
*
* 只补「刚好露出」的最小位移:正文比视口矮的时候,直接把正文滚进视口会把画面大幅上移,
* 打断正在读历史的人。正文已经完全落在视口里(或已收起、高度归零)时就只冻头部。
*/
export type ConversationToggleRevealGeometry = {
listRect: { top: number; bottom: number };
headRect: { top: number; bottom: number };
/** 折叠头在展开前相对视口顶边的位置。 */
headTop: number;
/** 折叠正文的几何;解析不出正文、或已收起传 null。 */
bodyRect: { top: number; bottom: number; height: number } | null;
};
export function toggleRevealDelta(
input: ConversationToggleRevealGeometry,
): number {
const freeze = input.headRect.top - input.headTop;
const body = input.bodyRect;
if (!body || body.height <= 0) return freeze;
// 冻结之后再判正文位置:位移已经把正文整体上移了 freeze 像素。
const bodyTop = body.top - freeze;
const bodyBottom = body.bottom - freeze;
const viewportHeight = input.listRect.bottom - input.listRect.top;
if (body.height > viewportHeight && bodyBottom > input.listRect.bottom) {
return freeze + Math.max(0, bodyTop - input.listRect.top);
}
if (bodyBottom > input.listRect.bottom) {
return freeze + (bodyBottom - input.listRect.bottom);
}
return freeze;
}
/**
* 布局变化后把折叠头拉回展开前的位置;返回是否做了补偿。
*
@@ -65,25 +105,12 @@ export function freezeToggleHead(
headRect.bottom > listRect.top && headRect.top < listRect.bottom;
if (!headVisible) return false;
list.scrollTop += headRect.top - fold.headTop;
revealExpandedBodyTop(list, fold.head);
const body = resolveToggleBody(fold.head);
list.scrollTop += toggleRevealDelta({
listRect,
headRect,
headTop: fold.headTop,
bodyRect: body?.getBoundingClientRect() ?? null,
});
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;
}