Files
Genarrative/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx
T
wuxiangwanzi 44ee28c43f
Project CI / Repository checks (push) Successful in 3m38s
Project CI / Frontend tests (push) Successful in 3m55s
Project CI / Backend tests (push) Successful in 4m7s
Project CI / Native shell tests (push) Failing after 9m31s
让运行视窗按内容自适应 (#180)
为本地预览注入只读尺寸桥并校验 iframe 消息来源

按可用区域等比缩放完整游戏画面并移除横纵滚动条

补充前端与 Rust 回归测试并同步运行视窗文档

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/180
Co-authored-by: 五香丸子 <15518898337@163.com>
Co-committed-by: 五香丸子 <15518898337@163.com>
2026-08-24 10:54:06 +08:00

233 lines
6.9 KiB
TypeScript

/* eslint-disable react-refresh/only-export-components -- The URL and fit helpers are exported with their small rendering adapter for focused tests. */
import { useEffect, useMemo, useRef, useState } from 'react';
export const LOCAL_GAME_PREVIEW_SIZE_MESSAGE =
'genarrative.local-preview-size.v1';
export type LocalGamePreviewContentSize = {
contentWidth: number;
contentHeight: number;
viewportWidth: number;
viewportHeight: number;
};
export type LocalGamePreviewFitLayout = {
width: number;
height: number;
scale: number;
};
export type LocalGamePreviewLike = {
status?: string | null;
url?: string | null;
};
export function resolveEmbeddedPreviewUrl(
preview: LocalGamePreviewLike | null | undefined,
) {
if (
!preview?.url ||
(preview.status !== undefined && preview.status !== 'running')
) {
return null;
}
try {
const url = new URL(preview.url);
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1') {
return null;
}
return url.toString();
} catch {
return null;
}
}
function positiveFiniteDimension(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.min(value, 100_000)
: null;
}
export function parseLocalGamePreviewContentSize(
value: unknown,
): LocalGamePreviewContentSize | null {
if (!value || typeof value !== 'object') return null;
const candidate = value as Record<string, unknown>;
if (candidate.type !== LOCAL_GAME_PREVIEW_SIZE_MESSAGE) return null;
const contentWidth = positiveFiniteDimension(candidate.contentWidth);
const contentHeight = positiveFiniteDimension(candidate.contentHeight);
const viewportWidth = positiveFiniteDimension(candidate.viewportWidth);
const viewportHeight = positiveFiniteDimension(candidate.viewportHeight);
if (
contentWidth === null ||
contentHeight === null ||
viewportWidth === null ||
viewportHeight === null
) {
return null;
}
return { contentWidth, contentHeight, viewportWidth, viewportHeight };
}
export function resolveLocalGamePreviewFitLayout(
container: { width: number; height: number },
content: LocalGamePreviewContentSize | null,
): LocalGamePreviewFitLayout {
const containerWidth = Math.max(1, container.width);
const containerHeight = Math.max(1, container.height);
if (!content) {
return { width: containerWidth, height: containerHeight, scale: 1 };
}
const width = Math.max(containerWidth, content.contentWidth);
const height = Math.max(containerHeight, content.contentHeight);
return {
width,
height,
scale: Math.min(1, containerWidth / width, containerHeight / height),
};
}
export function resolveLocalGamePreviewContentSizeUpdate(
current: LocalGamePreviewContentSize | null,
next: LocalGamePreviewContentSize,
nativeViewport: { width: number; height: number },
appliedViewport: { width: number; height: number },
): LocalGamePreviewContentSize | null {
const reportsViewport = (viewport: { width: number; height: number }) =>
Math.abs(next.viewportWidth - viewport.width) < 1 &&
Math.abs(next.viewportHeight - viewport.height) < 1;
if (!reportsViewport(nativeViewport) && !reportsViewport(appliedViewport)) {
return current;
}
if (
current &&
current.contentWidth === next.contentWidth &&
current.contentHeight === next.contentHeight
) {
return current;
}
return {
...next,
viewportWidth: nativeViewport.width,
viewportHeight: nativeViewport.height,
};
}
export function LocalGamePreviewFrame({
preview,
title,
className,
}: {
preview: LocalGamePreviewLike | null | undefined;
title: string;
className?: string;
}) {
const embeddedUrl = resolveEmbeddedPreviewUrl(preview);
const containerRef = useRef<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const measuredContainerSizeRef = useRef({ width: 1, height: 1 });
const contentSizeRef = useRef<LocalGamePreviewContentSize | null>(null);
const [containerSize, setContainerSize] = useState({ width: 1, height: 1 });
const [contentSize, setContentSize] =
useState<LocalGamePreviewContentSize | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const update = () => {
const rect = container.getBoundingClientRect();
const next = {
width: Math.max(1, rect.width),
height: Math.max(1, rect.height),
};
const current = measuredContainerSizeRef.current;
if (
Math.abs(current.width - next.width) < 0.5 &&
Math.abs(current.height - next.height) < 0.5
) {
return;
}
measuredContainerSizeRef.current = next;
setContainerSize(next);
if (contentSizeRef.current) {
contentSizeRef.current = null;
setContentSize(null);
}
};
update();
if (typeof window.ResizeObserver === 'function') {
const observer = new window.ResizeObserver(update);
observer.observe(container);
return () => observer.disconnect();
}
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [embeddedUrl]);
useEffect(() => {
if (contentSizeRef.current) {
contentSizeRef.current = null;
setContentSize(null);
}
}, [embeddedUrl]);
useEffect(() => {
if (!embeddedUrl) return;
const expectedOrigin = new URL(embeddedUrl).origin;
const handleMessage = (event: MessageEvent) => {
if (
event.origin !== expectedOrigin ||
event.source !== iframeRef.current?.contentWindow
) {
return;
}
const next = parseLocalGamePreviewContentSize(event.data);
if (!next) return;
const current = contentSizeRef.current;
const nativeViewport = measuredContainerSizeRef.current;
const appliedViewport = resolveLocalGamePreviewFitLayout(
nativeViewport,
current,
);
const resolved = resolveLocalGamePreviewContentSizeUpdate(
current,
next,
nativeViewport,
appliedViewport,
);
if (resolved === current) return;
contentSizeRef.current = resolved;
setContentSize(resolved);
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [embeddedUrl]);
const fit = useMemo(
() => resolveLocalGamePreviewFitLayout(containerSize, contentSize),
[containerSize, contentSize],
);
if (!embeddedUrl) {
return null;
}
return (
<div ref={containerRef} className="local-game-preview-frame">
<iframe
ref={iframeRef}
className={className}
title={title}
src={embeddedUrl}
scrolling="no"
style={{
width: `${fit.width}px`,
height: `${fit.height}px`,
transform: `translate(-50%, -50%) scale(${fit.scale})`,
}}
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
allow="autoplay; fullscreen; gamepad"
/>
</div>
);
}