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 9998161e2..edfedcc9c 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 @@ -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 { 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); diff --git a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts index 1b2521b90..4d238116f 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts @@ -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 = `
+
标题
+
+
`; + document.body.append(root); + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600)); + for (const element of root.querySelectorAll( + '.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('[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();