修复退出全屏后运行画面不回缩,并收口信息栏开合与文档口径
Project CI / AI game creator shell Rust crates (pull_request) Successful in 59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m26s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 2m5s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m40s
Project CI / Native shell tests (pull_request) Successful in 5m55s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m24s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m26s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 2m5s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m40s
Project CI / Native shell tests (pull_request) Successful in 5m55s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m24s
- `resolveLocalGamePreviewFitLayout` 增加回退:上报的内容尺寸等于它被接受时的容器尺寸(自适应页面把视口原样报回)时不算内容高水位,直接按容器取画布——修复「全屏预览退出后画布仍保持全屏比例、永久带黑边」;真比容器高的页面(内容 ≠ 视口)仍按原生尺寸缩放显示。 - 该回退的已知残余(固定尺寸页面恰好等于接受时的容器且之后不上报新尺寸会被裁)与「内容尺寸没变就改认新容器」这条更差尝试一起写进代码注释与 decision-log;根治方向记在桥/协议侧。 - `tests/localGamePreviewFrame.test.ts` 抽出 `renderFittedFrame` 夹具,补「退出全屏回到容器尺寸」(改前必红)与「容器缩小后按新内容尺寸重新适配」两条用例。 - 信息栏手动态改为按资源 id 在渲染期派生(去掉 effect 回写),修掉「刚有内容那一帧先画收起态」的抖动,并让 `onClick` 写入的字段与派生读的字段一致。 - AGC 子集删掉恒真的「数值微调面板不存在」断言,保留「卡片不渲染」与「开合行不渲染」两条独立事实。 - 文档同步:PRD §3.4、技术方案与 decision-log 的信息栏判据口径改为「资源选中态」,并记录全屏回归的根因、修法、残余边界与桥协议根治方向;2026-08-23 适配条目补一条指向本次收口。
This commit is contained in:
+17
-1
@@ -186,7 +186,23 @@ export function resolveLocalGamePreviewFitLayout(
|
||||
): LocalGamePreviewFitLayout {
|
||||
const containerWidth = Math.max(1, container.width);
|
||||
const containerHeight = Math.max(1, container.height);
|
||||
if (!content) {
|
||||
// 上报的「内容尺寸」等于它被接受时的容器尺寸,说明这个页面没有超出视口的固有内容——自适应的
|
||||
// 全屏游戏(Phaser `Scale.RESIZE` 那类)就是这样,桥把视口原样报回来。这份数字不携带固有尺寸,
|
||||
// 不能当高水位:否则运行视口一放大(例如全屏预览)就把画布钉在那个尺寸上,退出全屏后画布仍按
|
||||
// 全屏比例被缩进小容器、两边留出黑边,且再也回不去(iframe 视口不变 ⇒ 桥不会再报新尺寸)。
|
||||
// 这种页面继续让画布跟着容器走;真的比容器高的页面(内容 ≠ 视口)仍按原生尺寸缩放显示。
|
||||
//
|
||||
// 已知残余(不修,因为它与上面这条在数据上不可区分):某个**固定尺寸**页面恰好等于它被接受时
|
||||
// 的容器(1px 内),且缩小容器后上报的内容尺寸再不变,就会一直按容器取画布——页面自身溢出被
|
||||
// `overflow: hidden` 裁掉。改成「内容尺寸没变也把这条记录改认新容器」会反过来让上面那种自适应
|
||||
// 页面的过渡期上报(内容仍是放大前的旧值、视口已是新容器)被当成固有尺寸,全屏那类问题原样
|
||||
// 复现(实测过)。AGC 的桥对溢出文档才报出更大的内容尺寸,实测自适应与固定画布两种页面都报
|
||||
// 「内容 = 视口」,所以按自适应优先。
|
||||
if (
|
||||
!content ||
|
||||
(Math.abs(content.contentWidth - content.viewportWidth) < 1 &&
|
||||
Math.abs(content.contentHeight - content.viewportHeight) < 1)
|
||||
) {
|
||||
return { width: containerWidth, height: containerHeight, scale: 1 };
|
||||
}
|
||||
const width = Math.max(containerWidth, content.contentWidth);
|
||||
|
||||
@@ -3013,16 +3013,24 @@ export default function ProjectDevelopmentView({
|
||||
* 的只读字段。内容从无到有 / 从有到无都自动跟上(没有内容就自动收起),用户在同一段内容里
|
||||
* 手动收 / 展则一直有效,不会被别的渲染重开。
|
||||
*
|
||||
* 手动态不挂 effect、也不按下标存活,而是在渲染期按**资源 id** 判定:手动的收 / 展只对
|
||||
* 做出这个动作时的那张资源有效,换资源或清空后回到默认(有内容就展开)。这样「刚有内容」
|
||||
* 的那一帧就已经是展开态——挂 effect 回写会先画一帧收起态再展开,画面高度会抖一下。
|
||||
*
|
||||
* 「数值微调」暂时没有登记表(前端没有数据源),按用户口径没有功能就先不渲染这一栏;等
|
||||
* 后端编辑态登记表接进来后,它与它的内容一起进这个判据。
|
||||
*/
|
||||
const runPanelsHaveContent = selectedResource !== null;
|
||||
const [runPanelsExpanded, setRunPanelsExpanded] =
|
||||
useState(runPanelsHaveContent);
|
||||
const [runPanelsManualState, setRunPanelsManualState] = useState<{
|
||||
resourceId: string | null;
|
||||
expanded: boolean;
|
||||
} | null>(null);
|
||||
const runPanelsExpanded =
|
||||
runPanelsManualState !== null &&
|
||||
runPanelsManualState.resourceId === selectedResourceId
|
||||
? runPanelsManualState.expanded
|
||||
: runPanelsHaveContent;
|
||||
const runPanelsBodyId = useId();
|
||||
useEffect(() => {
|
||||
setRunPanelsExpanded(runPanelsHaveContent);
|
||||
}, [runPanelsHaveContent]);
|
||||
/**
|
||||
* 画布选中工具栏「编辑标签」的入口判定:
|
||||
* - 选中 1 项:既有的单素材标签编辑(增删标签行为不变);
|
||||
@@ -11085,7 +11093,10 @@ export default function ProjectDevelopmentView({
|
||||
aria-expanded={runPanelsExpanded}
|
||||
aria-controls={runPanelsBodyId}
|
||||
onClick={() =>
|
||||
setRunPanelsExpanded((current) => !current)
|
||||
setRunPanelsManualState({
|
||||
resourceId: selectedResourceId,
|
||||
expanded: !runPanelsExpanded,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ChevronDown
|
||||
|
||||
@@ -6458,10 +6458,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
|
||||
);
|
||||
expect(screen.queryByLabelText('测试切片控件')).toBeNull();
|
||||
// 底部信息栏没有内容就整栏不渲染:此时既没有选中资源(信息展示没有字段),也没有
|
||||
// 已登记的微调项。留着两张空卡片正是验收现场那半条「空信息栏白占一块高度」。
|
||||
// 底部信息栏没有内容就整栏不渲染:此时没有选中资源,「信息展示」拿不到字段,「数值微调」的
|
||||
// 登记表也还没接,于是条目卡片与开合行都不该出现——留着就是验收现场那半条「空信息栏白占一块高度」。
|
||||
expect(screen.queryByLabelText('资源信息面板')).toBeNull();
|
||||
expect(screen.queryByLabelText('数值微调面板')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '展开信息栏' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
|
||||
|
||||
@@ -6,77 +6,151 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
|
||||
type LocalGamePreviewContentSize,
|
||||
LocalGamePreviewFrame,
|
||||
parseLocalGamePreviewContentSize,
|
||||
resolveLocalGamePreviewContentSizeUpdate,
|
||||
resolveLocalGamePreviewFitLayout,
|
||||
} from '../src/features/project-workspace/LocalGamePreviewFrame';
|
||||
|
||||
/**
|
||||
* 运行画面用 `getBoundingClientRect` 量容器、用 `ResizeObserver` 跟踪;jsdom 两者都不给,这里补一份
|
||||
* 最小可驱动的:能改容器矩形、能手放 ResizeObserver 回调、能送跨窗口尺寸上报。
|
||||
*/
|
||||
function renderFittedFrame(initialContainer: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
let containerRect = initialContainer;
|
||||
let resizeCallback: ResizeObserverCallback | null = null;
|
||||
const rectSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...containerRect,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
right: containerRect.width,
|
||||
bottom: containerRect.height,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
const previousResizeObserver = window.ResizeObserver;
|
||||
window.ResizeObserver = class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
const view = render(
|
||||
createElement(LocalGamePreviewFrame, {
|
||||
preview: { status: 'running', url: 'http://127.0.0.1:1234/' },
|
||||
title: 'preview',
|
||||
}),
|
||||
);
|
||||
const iframe = view.getByTitle('preview') as HTMLIFrameElement;
|
||||
return {
|
||||
iframe,
|
||||
resizeTo(next: { width: number; height: number }) {
|
||||
containerRect = next;
|
||||
act(() => {
|
||||
if (!resizeCallback) {
|
||||
throw new Error('ResizeObserver was not registered');
|
||||
}
|
||||
resizeCallback([], {} as ResizeObserver);
|
||||
});
|
||||
},
|
||||
reportSize(size: LocalGamePreviewContentSize) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: 'http://127.0.0.1:1234',
|
||||
source: iframe.contentWindow,
|
||||
data: { type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, ...size },
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
cleanup() {
|
||||
view.unmount();
|
||||
window.ResizeObserver = previousResizeObserver;
|
||||
rectSpy.mockRestore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('local game preview viewport fitting', () => {
|
||||
it('does not reset the fitted iframe to native size while its container resizes', () => {
|
||||
let containerRect = { width: 800, height: 500 };
|
||||
let resizeCallback: ResizeObserverCallback | null = null;
|
||||
const rectSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...containerRect,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
right: containerRect.width,
|
||||
bottom: containerRect.height,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
const previousResizeObserver = window.ResizeObserver;
|
||||
window.ResizeObserver = class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
const view = render(
|
||||
createElement(LocalGamePreviewFrame, {
|
||||
preview: { status: 'running', url: 'http://127.0.0.1:1234/' },
|
||||
title: 'preview',
|
||||
}),
|
||||
);
|
||||
const iframe = view.getByTitle('preview') as HTMLIFrameElement;
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: 'http://127.0.0.1:1234',
|
||||
source: iframe.contentWindow,
|
||||
data: {
|
||||
type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
|
||||
contentWidth: 800,
|
||||
contentHeight: 835,
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 500,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const frame = renderFittedFrame({ width: 800, height: 500 });
|
||||
frame.reportSize({
|
||||
contentWidth: 800,
|
||||
contentHeight: 835,
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 500,
|
||||
});
|
||||
expect(iframe.style.height).toBe('835px');
|
||||
expect(frame.iframe.style.height).toBe('835px');
|
||||
|
||||
containerRect = { width: 1000, height: 600 };
|
||||
act(() => {
|
||||
if (!resizeCallback) throw new Error('ResizeObserver was not registered');
|
||||
resizeCallback([], {} as ResizeObserver);
|
||||
frame.resizeTo({ width: 1000, height: 600 });
|
||||
expect(frame.iframe.style.width).toBe('1000px');
|
||||
expect(frame.iframe.style.height).toBe('835px');
|
||||
|
||||
frame.cleanup();
|
||||
});
|
||||
|
||||
it('returns the fitted iframe to the container after the host viewport shrinks', () => {
|
||||
// 全屏预览把运行视口放大到 1416x808,自适应游戏把视口原样报回来;退出全屏后画布必须跟着
|
||||
// 缩回容器尺寸,不能把全屏那一帧的尺寸钉在画布上(改前这里会一直是 1416x808 + 缩放到 0.72)。
|
||||
const frame = renderFittedFrame({ width: 1416, height: 808 });
|
||||
frame.reportSize({
|
||||
contentWidth: 1416,
|
||||
contentHeight: 808,
|
||||
viewportWidth: 1416,
|
||||
viewportHeight: 808,
|
||||
});
|
||||
expect(iframe.style.width).toBe('1000px');
|
||||
expect(iframe.style.height).toBe('835px');
|
||||
expect(frame.iframe.style.width).toBe('1416px');
|
||||
expect(frame.iframe.style.height).toBe('808px');
|
||||
|
||||
view.unmount();
|
||||
window.ResizeObserver = previousResizeObserver;
|
||||
rectSpy.mockRestore();
|
||||
frame.resizeTo({ width: 1015, height: 660 });
|
||||
expect(frame.iframe.style.width).toBe('1015px');
|
||||
expect(frame.iframe.style.height).toBe('660px');
|
||||
|
||||
frame.cleanup();
|
||||
});
|
||||
|
||||
it('refits to the reported content size after the container shrinks', () => {
|
||||
// 容器缩小后先按容器取画布(上一次的内容尺寸已不能代表当前容器),页面在新容器上重新量出
|
||||
// 更大的内容(真的溢出)时,画布回到 `max(容器, 内容)` 并把整幅内容等比缩小。
|
||||
const frame = renderFittedFrame({ width: 1015, height: 660 });
|
||||
frame.reportSize({
|
||||
contentWidth: 1015,
|
||||
contentHeight: 660,
|
||||
viewportWidth: 1015,
|
||||
viewportHeight: 660,
|
||||
});
|
||||
expect(frame.iframe.style.width).toBe('1015px');
|
||||
|
||||
frame.resizeTo({ width: 800, height: 520 });
|
||||
expect(frame.iframe.style.width).toBe('800px');
|
||||
|
||||
frame.reportSize({
|
||||
contentWidth: 1200,
|
||||
contentHeight: 900,
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 520,
|
||||
});
|
||||
expect(frame.iframe.style.width).toBe('1200px');
|
||||
expect(frame.iframe.style.height).toBe('900px');
|
||||
expect(
|
||||
Number(/scale\(([\d.]+)\)/u.exec(frame.iframe.style.transform)?.[1]),
|
||||
).toBeCloseTo(520 / 900, 10);
|
||||
|
||||
frame.cleanup();
|
||||
});
|
||||
|
||||
it('keeps the current fit while the iframe reports its first host-applied viewport measurement', () => {
|
||||
|
||||
Reference in New Issue
Block a user