修复返回总览时非活动栏目标题栏从屏幕外飞入

- begin 只记录当时可见的节点:不可见节点(内联 opacity 0)的屏幕矩形不是可信的 First
- 非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,返回总览时不再以它作为动画起点
- 没有 First 的节点只走淡入,位置保持不动;活动栏目标题栏仍按原有观感收回到总览位置
- 进入栏目方向不受影响:标题栏当时可见,仍钉在原地淡出
- 新增回归用例:不可见标题栏几何首末相等、活动标题栏仍有几何动画、进入方向标题栏原地淡出
- 同步技术方案转场段与 pitfalls 经验条目
This commit is contained in:
2026-09-09 21:39:20 +08:00
parent 4fb0de2c94
commit 4a5b2ad524
4 changed files with 92 additions and 5 deletions
@@ -511,17 +511,26 @@ export function createResourceBookTransitionController() {
};
return {
/** 状态提交前记录 First:真实元素的屏幕矩形和当前不透明度。 */
/**
* 状态提交前记录 First:真实元素的屏幕矩形和当前不透明度。
*
* 只记录当前可见的节点。不可见节点(内联 opacity 0)的屏幕矩形是"看不见的位置":
* 非活动栏目的标题栏在子画布态就属于这种情况——它的布局坐标还是总览矩形,但被
* 子画布 world 变换推到了屏幕外。若把它当 First,返回总览时标题栏会从屏幕外飞
* 进来。没有 First 的节点只走淡入,位置保持不动。
*/
begin(manager: HTMLElement | null) {
const captured = new Map<string, MotionSnapshotEntry>();
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();
@@ -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();
@@ -42,6 +42,7 @@
- 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewportFLIP 的 translate 写在 world 局部坐标系,屏幕位移必须除以 world 缩放,宿主节点原点不在卡片左上角时(卡片包装节点是 0x0)还要按原点偏移换算。
- 克隆快照 + 动画结束交接给真实节点这条路线有两个结构性缺陷,反复补丁也治不了:一是交接要求两个不同 DOM 对象的终态像素级一致;二是快照层在 world 之外、真实卡片在 world 之内,克隆坐标是屏幕空间、真实节点是 world 局部空间,交接时必然要重映射一次坐标。共享元素转场只在"同一批 DOM 节点、同一父节点"成立时才用真实元素 FLIP,不要引入克隆层。
- 卡片尺寸依赖异步到达的预览图尺寸,布局会在转场动画之后(甚至动画结束之后)再变一次。所有布局变化必须走同一套"同一节点反向 transform",并在动画运行中以"此刻像素"为起点重基(转场中取剩余时长,下限 80ms;空闲布局变化取完整时长),否则晚到的那次布局变化仍是瞬移。
- FLIP 的 First 只能取**当时可见**的节点。不可见节点的屏幕矩形是"看不见的位置":非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,把它当 First 就会让标题栏在返回总览时从屏幕外飞进来。没有 First 的节点只淡入、位置不动;进入栏目方向它们仍然可见,因此照旧钉在原地淡出。
- 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。
- jsdom 不执行真实转场;定向模型测试之外,仍需浏览器核验中断连续性、图片/文字尺寸、viewport 保留和窄屏布局。完整契约见资源自由画板技术方案。
@@ -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 完成。