修复资源画本转场克隆的层级与静态不一致

转场层里的克隆只按自身 DOM 顺序叠放,而静态场景中卡片是 25、标题栏是 30,导致动画期间卡片盖住标题栏、结束后又瞬间跳回下方
快照记录元素自身或最近定位祖先的 z-index,克隆按该值设置层级,保持静态层叠关系
补充 resourceBookController 层级回归用例
This commit is contained in:
2026-09-09 16:22:07 +08:00
parent 2c5ad3903e
commit 985db358a7
2 changed files with 61 additions and 0 deletions
@@ -5,6 +5,7 @@ type Snapshot = {
key: string;
rect: { left: number; top: number; width: number; height: number };
opacity: number;
zIndex: number;
content: HTMLElement;
};
@@ -43,6 +44,24 @@ function freezeBlobImageSource(image: HTMLImageElement) {
}
}
/**
* 转场层里的克隆只按自己的 DOM 顺序叠放,和静态场景的层级不一致:卡片会盖住
* 标题栏,动画结束再瞬间跳回下方。取元素自身或最近定位祖先的 z-index,让克隆
* 保持静态时的层叠关系。
*/
function effectiveZIndex(element: HTMLElement, root: HTMLElement) {
for (
let node: HTMLElement | null = element;
node;
node = node.parentElement
) {
const parsed = Number.parseInt(getComputedStyle(node).zIndex, 10);
if (Number.isFinite(parsed)) return parsed;
if (node === root) break;
}
return 0;
}
// Freeze presentation only. Clones cannot expose duplicate controls, replay
// media, or depend on CSS selectors belonging to the old ancestor tree.
function clonePresentation(
@@ -188,6 +207,7 @@ function capture(root: HTMLElement): Map<string, Snapshot> {
key,
rect,
opacity,
zIndex: effectiveZIndex(element, root),
content: clonePresentation(
element.dataset.motionSnapshot
? (element.firstElementChild as HTMLElement)
@@ -285,6 +305,7 @@ export function createResourceBookTransitionController() {
height: `${item.rect.height}px`,
transformOrigin: '0 0',
pointerEvents: 'none',
zIndex: String(item.zIndex),
});
wrapper.append(item.content);
layer.append(wrapper);
@@ -138,6 +138,46 @@ describe('resource book FLIP controller', () => {
controller.dispose();
});
it('keeps the static stacking order inside the transition layer', () => {
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
writable: true,
value: () => ({
finished: Promise.resolve({} as Animation),
cancel: () => undefined,
}),
});
const root = document.createElement('div');
root.innerHTML = `<div class="game-resource-book-scene"><div class="game-resource-book-scene-world">
<div class="game-resource-book-scene-titlebar" style="z-index: 30"><span>标题</span></div>
<div class="game-resource-book-scene-card" style="z-index: 25"><div class="game-resource-card" data-resource-card-id="one"><img src="blob:card-preview" alt="" /></div></div>
</div></div><div class="game-resource-book-transition-layer" inert aria-hidden="true"></div>`;
document.body.append(root);
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600));
for (const element of root.querySelectorAll<HTMLElement>(
'.game-resource-book-scene-titlebar, .game-resource-card',
)) {
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect());
}
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
controller.play(root, token, () => undefined);
const wrappers = Array.from(
root.querySelectorAll<HTMLElement>('[data-motion-snapshot]'),
);
const titleWrapper = wrappers.find((element) =>
element.dataset.motionSnapshot?.startsWith('title:'),
);
const cardWrapper = wrappers.find((element) =>
element.dataset.motionSnapshot?.startsWith('card:'),
);
expect(Number(titleWrapper?.style.zIndex)).toBe(30);
expect(Number(cardWrapper?.style.zIndex)).toBe(25);
controller.dispose();
});
it('retargets from the current overlay geometry and ignores old completion', async () => {
const { root, measure, flights } = setup();
const controller = createResourceBookTransitionController();