From 0cf649b0991ad15d7f9e9c81e352701c6cef9119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Sun, 23 Aug 2026 17:21:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=AE=A9=E8=BF=90=E8=A1=8C=E8=A7=86?= =?UTF-8?q?=E7=AA=97=E6=8C=89=E5=86=85=E5=AE=B9=E8=87=AA=E9=80=82=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为本地预览注入只读尺寸桥并校验 iframe 消息来源 按可用区域等比缩放完整游戏画面并移除横纵滚动条 补充前端与 Rust 回归测试并同步运行视窗文档 --- .../src-tauri/src/preview.rs | 86 +++++++++ .../src-tauri/src/tests/project.rs | 22 +++ .../LocalGamePreviewFrame.tsx | 165 +++++++++++++++++- apps/ai-game-creator-shell/src/styles.css | 24 ++- .../tests/localGamePreviewFrame.test.ts | 58 ++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 5 +- .../shared-memory/decision-log.md | 6 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 8 files changed, 350 insertions(+), 18 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index f8846b851..e9f664963 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -89,6 +89,53 @@ const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024; const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100; const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024; +const PREVIEW_FIT_BRIDGE_PATH: &str = "/__genarrative/local-preview-fit.js"; +const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => { + const messageType = 'genarrative.local-preview-size.v1'; + let ready = document.readyState === 'complete'; + let frame = 0; + const publish = () => { + frame = 0; + if (!ready || window.parent === window) return; + const root = document.documentElement; + const body = document.body; + window.parent.postMessage({ + type: messageType, + contentWidth: Math.ceil(Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0)), + contentHeight: Math.ceil(Math.max(root?.scrollHeight || 0, body?.scrollHeight || 0)), + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }, '*'); + }; + const schedule = () => { + if (frame === 0) frame = window.requestAnimationFrame(publish); + }; + const activate = () => { + ready = true; + const style = document.createElement('style'); + style.dataset.genarrativePreviewFit = 'true'; + style.textContent = 'html,body{scrollbar-width:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}'; + (document.head || document.documentElement).appendChild(style); + schedule(); + if (typeof ResizeObserver === 'function') { + const resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(document.documentElement); + if (document.body) resizeObserver.observe(document.body); + } + const mutationObserver = new MutationObserver(schedule); + mutationObserver.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + window.addEventListener('resize', schedule, { passive: true }); + }; + if (ready) activate(); + else window.addEventListener('load', activate, { once: true }); +})();"#; +const PREVIEW_FIT_BRIDGE_TAG: &str = + r#""#; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PreviewListenerAcceptDisposition { @@ -560,6 +607,20 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) ); } + if url_path.split('?').next() == Some(PREVIEW_FIT_BRIDGE_PATH) { + let body = if is_head { + Vec::new() + } else { + PREVIEW_FIT_BRIDGE_SCRIPT.as_bytes().to_vec() + }; + return http_response( + "200 OK", + "text/javascript; charset=utf-8", + &body, + PREVIEW_FIT_BRIDGE_SCRIPT.len(), + ); + } + let file_path = match resolve_preview_path(root, url_path) { Ok(path) => path, Err(_) => { @@ -574,11 +635,36 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) return http_response("404 Not Found", "text/plain", body, b"not found".len()); } }; + let body = if content_type(&file_path).starts_with("text/html") { + inject_preview_fit_bridge(body) + } else { + body + }; let content_length = body.len(); let body = if is_head { Vec::new() } else { body }; http_response("200 OK", content_type(&file_path), &body, content_length) } +fn inject_preview_fit_bridge(body: Vec) -> Vec { + let html = match String::from_utf8(body) { + Ok(html) => html, + Err(error) => return error.into_bytes(), + }; + if html.contains(PREVIEW_FIT_BRIDGE_PATH) { + return html.into_bytes(); + } + let lowercase = html.to_ascii_lowercase(); + let insertion = lowercase + .rfind("") + .or_else(|| lowercase.rfind("")) + .unwrap_or(html.len()); + let mut output = String::with_capacity(html.len() + PREVIEW_FIT_BRIDGE_TAG.len()); + output.push_str(&html[..insertion]); + output.push_str(PREVIEW_FIT_BRIDGE_TAG); + output.push_str(&html[insertion..]); + output.into_bytes() +} + pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result { let path = url_path.split('?').next().unwrap_or("/"); let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 1167a8440..0bcef57cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3440,6 +3440,28 @@ fn local_preview_server_serves_game_index() { assert!(response.contains("200 OK"), "{response}"); assert!(response.contains("还没有生成游戏")); + assert!( + response.contains("/__genarrative/local-preview-fit.js"), + "{response}" + ); + + let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); + stream + .write_all(b"GET /__genarrative/local-preview-fit.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .expect("fit bridge request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("fit bridge response"); + assert!(response.contains("200 OK"), "{response}"); + assert!( + response.contains("Content-Type: text/javascript; charset=utf-8"), + "{response}" + ); + assert!( + response.contains("genarrative.local-preview-size.v1"), + "{response}" + ); let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); stream diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index 401757601..9da588be4 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -1,4 +1,22 @@ -/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */ +/* 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; @@ -25,6 +43,59 @@ export function resolveEmbeddedPreviewUrl( } } +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; + 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.viewportWidth, + content.contentWidth, + ); + const height = Math.max( + containerHeight, + content.viewportHeight, + content.contentHeight, + ); + return { + width, + height, + scale: Math.min(1, containerWidth / width, containerHeight / height), + }; +} + export function LocalGamePreviewFrame({ preview, title, @@ -35,16 +106,94 @@ export function LocalGamePreviewFrame({ className?: string; }) { const embeddedUrl = resolveEmbeddedPreviewUrl(preview); + const containerRef = useRef(null); + const iframeRef = useRef(null); + const measuredContainerSizeRef = useRef({ width: 1, height: 1 }); + const [containerSize, setContainerSize] = useState({ width: 1, height: 1 }); + const [contentSize, setContentSize] = + useState(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); + 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(() => { + 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; + setContentSize((current) => { + if (!current) return next; + const reportsContainerViewport = + Math.abs(next.viewportWidth - containerSize.width) < 1 && + Math.abs(next.viewportHeight - containerSize.height) < 1; + return reportsContainerViewport ? next : current; + }); + }; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [containerSize.height, containerSize.width, embeddedUrl]); + + const fit = useMemo( + () => resolveLocalGamePreviewFitLayout(containerSize, contentSize), + [containerSize, contentSize], + ); if (!embeddedUrl) { return null; } return ( -