修复资源画本转场快照丢失卡片预览图

快照克隆原先直接复制 blob: 对象 URL,源卡片卸载或预览缓存驱逐后该 URL 被 revoke,克隆里的图片随即失效,表现为进入/退出子画布时卡片框在动而图片不跟着展开和复原
对 blob: 来源且已解码的图片在克隆时栅格化为 data URL 冻结,与既有视频快照处理保持一致
补充 resourceBookController 快照图片冻结回归用例
This commit is contained in:
2026-09-09 15:55:05 +08:00
parent 6d3d24df5e
commit 79cf932cba
2 changed files with 63 additions and 1 deletions
@@ -23,6 +23,26 @@ function validRect(rect: Snapshot['rect']) {
);
}
// 卡片预览用 object URL 渲染;源节点卸载或预览缓存驱逐会 revoke 掉它。
// 快照必须自带像素,否则展开/复原过程中图片会直接消失。
function freezeBlobImageSource(image: HTMLImageElement) {
const source = image.currentSrc || image.getAttribute('src') || '';
if (!source.startsWith('blob:')) return null;
if (!image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0)
return null;
try {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext('2d');
if (!context) return null;
context.drawImage(image, 0, 0);
return canvas.toDataURL();
} catch {
return null;
}
}
// Freeze presentation only. Clones cannot expose duplicate controls, replay
// media, or depend on CSS selectors belonging to the old ancestor tree.
function clonePresentation(
@@ -74,6 +94,10 @@ function clonePresentation(
copy.style.animation = 'none';
copy.style.pointerEvents = 'none';
copy.setAttribute('tabindex', '-1');
if (original instanceof HTMLImageElement) {
const frozen = freezeBlobImageSource(original);
if (frozen) copy.setAttribute('src', frozen);
}
if (copy instanceof HTMLMediaElement) {
copy.removeAttribute('src');
copy.querySelectorAll('source').forEach((item) => item.remove());
@@ -23,7 +23,7 @@ function rect(left = 0, top = 0, width = 100, height = 80): DOMRect {
function fixture() {
const root = document.createElement('div');
root.innerHTML = `<div class="game-resource-book-scene"><div class="game-resource-book-scene-world">
<div class="game-resource-card" data-resource-card-id="one"><button id="original">Open</button></div>
<div class="game-resource-card" data-resource-card-id="one"><img src="blob:card-preview" alt="" /><button id="original">Open</button></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));
@@ -100,6 +100,44 @@ describe('resource book FLIP controller', () => {
controller.dispose();
});
it('freezes blob-backed card previews into the snapshot', () => {
const { root, measure } = setup();
const image = root.querySelector<HTMLImageElement>(
'.game-resource-card img',
)!;
Object.defineProperty(image, 'complete', {
configurable: true,
value: true,
});
Object.defineProperty(image, 'naturalWidth', {
configurable: true,
value: 8,
});
Object.defineProperty(image, 'naturalHeight', {
configurable: true,
value: 6,
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
'data:image/png;base64,FROZEN',
);
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
measure.mockReturnValue(rect(150, 100, 200, 160));
controller.play(root, token, () => undefined);
const snapshotImage = root.querySelector<HTMLImageElement>(
'.game-resource-book-transition-layer img',
);
expect(snapshotImage?.getAttribute('src')).toBe(
'data:image/png;base64,FROZEN',
);
controller.dispose();
});
it('retargets from the current overlay geometry and ignores old completion', async () => {
const { root, measure, flights } = setup();
const controller = createResourceBookTransitionController();