From 4a5b2ad524dd101e0966bfac7d359831119e5f1a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 9 Sep 2026 21:39:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=94=E5=9B=9E=E6=80=BB?= =?UTF-8?q?=E8=A7=88=E6=97=B6=E9=9D=9E=E6=B4=BB=E5=8A=A8=E6=A0=8F=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=A0=8F=E4=BB=8E=E5=B1=8F=E5=B9=95=E5=A4=96?= =?UTF-8?q?=E9=A3=9E=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - begin 只记录当时可见的节点:不可见节点(内联 opacity 0)的屏幕矩形不是可信的 First - 非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,返回总览时不再以它作为动画起点 - 没有 First 的节点只走淡入,位置保持不动;活动栏目标题栏仍按原有观感收回到总览位置 - 进入栏目方向不受影响:标题栏当时可见,仍钉在原地淡出 - 新增回归用例:不可见标题栏几何首末相等、活动标题栏仍有几何动画、进入方向标题栏原地淡出 - 同步技术方案转场段与 pitfalls 经验条目 --- .../resourceBookController.ts | 19 +++-- .../tests/resourceBookController.test.ts | 76 +++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 1 + ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 1 + 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts index 5f778e4f8..09b9978e7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts @@ -511,17 +511,26 @@ export function createResourceBookTransitionController() { }; return { - /** 状态提交前记录 First:真实元素的屏幕矩形和当前不透明度。 */ + /** + * 状态提交前记录 First:真实元素的屏幕矩形和当前不透明度。 + * + * 只记录当前可见的节点。不可见节点(内联 opacity 0)的屏幕矩形是"看不见的位置": + * 非活动栏目的标题栏在子画布态就属于这种情况——它的布局坐标还是总览矩形,但被 + * 子画布 world 变换推到了屏幕外。若把它当 First,返回总览时标题栏会从屏幕外飞 + * 进来。没有 First 的节点只走淡入,位置保持不动。 + */ begin(manager: HTMLElement | null) { const captured = new Map(); if (manager) { for (const node of collectNodes(manager)) { + const opacity = readVisualOpacity( + node.host, + readTargetOpacity(node.host), + ); + if (opacity <= 0.001) continue; const rect = readRect(node.box); if (!resourceBookValidMotionRect(rect)) continue; - captured.set(node.key, { - rect, - opacity: readVisualOpacity(node.host, readTargetOpacity(node.host)), - }); + captured.set(node.key, { rect, opacity }); } } invalidate(); diff --git a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts index ca9cee8a2..ea4a41af4 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts @@ -266,6 +266,82 @@ describe('resource book real element FLIP', () => { controller.dispose(); }); + it('does not move a titlebar that was invisible when the transition started', () => { + const flights = installAnimateMock(); + const { manager, cardHost, cardBox, titleHost, setRect, signatures } = + fixture(); + setRect(cardHost, { left: 0, top: 0, width: 0, height: 0 }); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + // 子画布静止态:非活动标题栏不可见,屏幕位置来自子画布 world,可能远在屏幕外。 + titleHost.style.opacity = '0'; + setRect(titleHost, { left: -2_000, top: -1_500, width: 280, height: 42 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + + // 返回总览:标题栏回到总览矩形并可见,卡片收回摞上。 + titleHost.style.opacity = '1'; + setRect(titleHost, { left: 0, top: 0, width: 280, height: 42 }); + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); + controller.play(manager, token, () => undefined, signatures); + + const titleFlight = flights.find((flight) => flight.host === titleHost); + // 允许淡入,但几何首末必须相等:不能从屏幕外飞进来。 + if (titleFlight) { + expect(titleFlight.frames[0]!.transform).toBe( + titleFlight.frames[1]!.transform, + ); + } + const cardFlight = flights.find((flight) => flight.host === cardHost)!; + expect(cardFlight.frames[0]!.transform).not.toBe( + cardFlight.frames[1]!.transform, + ); + controller.dispose(); + }); + + it('still animates the active titlebar back to the overview stack', () => { + const flights = installAnimateMock(); + const { manager, cardBox, titleHost, setRect, signatures } = fixture(); + // 子画布静止态:活动标题栏用抵消 world 的内联 transform 钉在屏幕上。 + vi.mocked(titleHost.getBoundingClientRect).mockImplementation(() => + titleHost.style.transform + ? toDomRect({ left: 0, top: 0, width: 560, height: 84 }) + : toDomRect({ left: 500, top: 300, width: 280, height: 42 }), + ); + titleHost.style.transform = 'translate(-300px, -200px) scale(2)'; + titleHost.style.opacity = '1'; + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + + titleHost.style.transform = ''; + controller.play(manager, token, () => undefined, signatures); + + const flight = flights.find((entry) => entry.host === titleHost)!; + expect(flight.frames[0]!.transform).not.toBe(flight.frames[1]!.transform); + controller.dispose(); + }); + + it('fades a titlebar out in place while entering a category', () => { + const flights = installAnimateMock(); + const { manager, cardBox, titleHost, setRect, signatures } = fixture(); + titleHost.style.opacity = '1'; + setRect(titleHost, { left: 0, top: 0, width: 280, height: 42 }); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + + // 进入栏目:标题栏不可见,屏幕位置来自子画布 world。 + titleHost.style.opacity = '0'; + setRect(titleHost, { left: -2_000, top: -1_500, width: 280, height: 42 }); + controller.play(manager, token, () => undefined, signatures); + + const flight = flights.find((entry) => entry.host === titleHost)!; + expect(flight.frames[0]!.transform).toBe(flight.frames[1]!.transform); + expect(flight.frames[0]!.opacity).toBe(1); + expect(flight.frames[1]!.opacity).toBe(0); + controller.dispose(); + }); + it('fades in a card that mounts during the transition', () => { const flights = installAnimateMock(); const { manager, cardHost, cardBox, setRect, signatures } = fixture(); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 46a4d62d3..dd59351c6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -42,6 +42,7 @@ - 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;FLIP 的 translate 写在 world 局部坐标系,屏幕位移必须除以 world 缩放,宿主节点原点不在卡片左上角时(卡片包装节点是 0x0)还要按原点偏移换算。 - 克隆快照 + 动画结束交接给真实节点这条路线有两个结构性缺陷,反复补丁也治不了:一是交接要求两个不同 DOM 对象的终态像素级一致;二是快照层在 world 之外、真实卡片在 world 之内,克隆坐标是屏幕空间、真实节点是 world 局部空间,交接时必然要重映射一次坐标。共享元素转场只在"同一批 DOM 节点、同一父节点"成立时才用真实元素 FLIP,不要引入克隆层。 - 卡片尺寸依赖异步到达的预览图尺寸,布局会在转场动画之后(甚至动画结束之后)再变一次。所有布局变化必须走同一套"同一节点反向 transform",并在动画运行中以"此刻像素"为起点重基(转场中取剩余时长,下限 80ms;空闲布局变化取完整时长),否则晚到的那次布局变化仍是瞬移。 +- FLIP 的 First 只能取**当时可见**的节点。不可见节点的屏幕矩形是"看不见的位置":非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,把它当 First 就会让标题栏在返回总览时从屏幕外飞进来。没有 First 的节点只淡入、位置不动;进入栏目方向它们仍然可见,因此照旧钉在原地淡出。 - 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。 - jsdom 不执行真实转场;定向模型测试之外,仍需浏览器核验中断连续性、图片/文字尺寸、viewport 保留和窄屏布局。完整契约见资源自由画板技术方案。 diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index fc720e7cb..199ecb18c 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -7,6 +7,7 @@ - 反向 transform 写在宿主节点自身坐标系:`transform-origin: 0 0`,缩放比取 `First.size / Last.size`,位移按宿主原点偏移换算后除以 `.game-resource-book-scene-world` 的缩放。标题栏激活时内联的"抵消 world"transform 作为 base 与反向 transform 组合,结束帧回到该 base。 - `begin` 之后的所有布局提交都调 `sync`:比较每个节点的布局签名,变化时取消旧动画、重新测量,以**当前视觉矩形**(含运行中的 transform)为起点重基到新的干净终点。转场进行中用剩余时长(`startTime + 420 - now`,下限 80ms),空闲布局变化用完整 420ms。卡片预览尺寸是异步到达的,因此"动画期间甚至动画结束之后"的二次布局变化同样是动画而不是瞬移。窗口/容器尺寸变化与拖拽中的卡片不做布局动画。 - 目标不可见的节点(内联 `opacity` 为 0)钉在 First 位置淡出,没有 First 的挂载节点淡入;搜索/排序导致的卸载保留一帧后用同一套淡出撤下,进入栏目期间其他栏目保留挂载淡出,返回总览时源栏目未回到摞上的卡片按子画布布局淡出。转场时长统一 420ms;动画创建失败、零尺寸、容器尺寸变化或 `prefers-reduced-motion` 时不创建动画,也不等待定时器。 +- `begin` 只记录**当时可见**的节点。不可见节点的屏幕矩形是"看不见的位置":非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,若把它当 First,返回总览时标题栏会从屏幕外飞进来。不可见节点没有 First,只走淡入,位置保持不动;进入栏目方向它们仍然可见,因此照旧钉在原地淡出。同理,卡片只有在提交前可见时才参与几何 FLIP。 - 再次导航先读取当前像素位置,再取消旧动画并重新建立目标;token 隔离旧 promise,过期完成和取消均不能提交新状态。 - 缩放、适应内容和空白拖动先结束视觉转场再执行用户输入;转场中的资源卡 pointer-down 不启动持久化拖拽。普通滚轮只平移当前视图,Ctrl/Meta 滚轮以指针为锚点缩放,均不切换栏目。 - viewport 只属于用户交互状态,按主画布以及 `sortMode/category` 隔离保存。转场不插值也不持久化 viewport;测量主画布缩略入口时先逆变换回布局坐标,避免缩放后的 DOMRect 被二次缩放。只有真实非零测量才能标记首次 fit 完成。