From ecb84f1e703d091b2c4d2ec709777238eb7aa130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 18:18:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=9D=97=E6=97=B6=E6=8A=8A=E6=96=B0=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E7=9A=84=E6=AD=A3=E6=96=87=E9=9C=B2=E5=87=BA=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增纯函数 toggleRevealDelta:折叠头冻结与露出正文合成一次 scrollTop 位移 - 正文底边超出视口只补超出量,正文比视口还高才对齐正文顶边,收起不额外滚动 - 补齐用例:纯函数六例与 freezeToggleHead 两条真机行为 Co-authored-by: Junie --- .../conversationToggleReveal.test.ts | 93 +++++++++++++++++-- .../conversationToggleReveal.ts | 67 +++++++++---- 2 files changed, 134 insertions(+), 26 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts index 021fd7cc4..e75996750 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts @@ -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('头部已经不在视口里:不补偿,交给锚点还原', () => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts index 1956fbc94..56924012f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts @@ -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; -}