WIP: AGC 资源画布与替换改造 V3.0 #316
@@ -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 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;FLIP 的 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 完成。
|
||||
|
||||
Reference in New Issue
Block a user