44ee28c43f
为本地预览注入只读尺寸桥并校验 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>
233 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|