运行页签补齐全屏预览与可收起的信息栏
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m5s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m5s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
- 运行画面右下角新增「全屏预览」:新增 `useElementFullscreen`,只把画面那一格(`.game-run-preview`)送进全屏,按钮态只认 `fullscreenchange`,宿主没有 Fullscreen API 时整枚入口不渲染。 - 底部信息栏改为「有内容才存在」:运行页选中资源时自动展开,可手动收起到只剩一行开合按钮,清空选中后整栏不渲染;只有一栏内容时卡片铺满整行。 - 「数值微调」在没有登记表前不渲染区域标题与卡片,随这一栏删除其 label / input 样式声明。 - 新增 `tests/runPreviewFullscreen.test.tsx`;AGC 子集补信息栏开合用例,并把「没有内容仍渲染两张空卡片」的旧断言改成整栏不渲染。 - 同步 PRD §3.4、技术方案运行表现层条目与 project-memory 决策记录。
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export type ElementFullscreenController<T extends HTMLElement> = {
|
||||
/** 要全屏的那一格;挂在它的 `ref` 上。 */
|
||||
ref: RefObject<T | null>;
|
||||
isFullscreen: boolean;
|
||||
isSupported: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行画面「全屏预览」用的**元素级**全屏。
|
||||
*
|
||||
* 只走标准 Fullscreen API:宿主是浏览器还是客户端 WebView 都是同一份实现。**不接 Tauri 的
|
||||
* 窗口级全屏**——那是把整块工作台(连对话栏)放大,语义是「全屏应用」,不是「全屏预览画面」。
|
||||
*
|
||||
* 支持判据是两层:`requestFullscreen` 真的在,且没有被宿主显式关掉
|
||||
* (`fullscreenEnabled === false`,例如被权限策略挡住)。任一不成立就不渲染这枚按钮——
|
||||
* 一枚点了没反应的全屏按钮比没有按钮更糟。
|
||||
*
|
||||
* 全屏元素被移除时浏览器按规范自己退出全屏(切回资源页签、切项目都走这条),所以这里不为
|
||||
* 卸载补退出逻辑;按钮态只认 `fullscreenchange`,Esc 与宿主自己退出都会回落。
|
||||
*/
|
||||
export function useElementFullscreen<
|
||||
T extends HTMLElement,
|
||||
>(): ElementFullscreenController<T> {
|
||||
const elementRef = useRef<T | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const isSupported =
|
||||
typeof document !== 'undefined' &&
|
||||
document.fullscreenEnabled !== false &&
|
||||
typeof document.documentElement?.requestFullscreen === 'function';
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
// 事件挂在全局 `document`、在回调里读 ref:运行画面是条件挂载的,按 ref 订监听会在
|
||||
// 「进运行页签之前」就订不上,退出全屏(Esc、F11、宿主)再也收不回来。
|
||||
const sync = () => {
|
||||
const element = elementRef.current;
|
||||
setIsFullscreen(
|
||||
element !== null && document.fullscreenElement === element,
|
||||
);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', sync);
|
||||
sync();
|
||||
return () => document.removeEventListener('fullscreenchange', sync);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const ownerDocument = element.ownerDocument;
|
||||
// 已经有人在全屏(本元素,或页面里别的东西)时这一步只负责退出:退出本身幂等,
|
||||
// 不需要先判断当前全屏的是不是自己。
|
||||
if (ownerDocument.fullscreenElement) {
|
||||
void ownerDocument.exitFullscreen?.()?.catch(() => {});
|
||||
return;
|
||||
}
|
||||
// 失败(用户手势丢失、权限策略拒绝)不改按钮状态,界面回到「还是没全屏」的原样;
|
||||
// 拒绝的 Promise 必须接住,否则会冒成未处理拒绝。
|
||||
void element.requestFullscreen?.()?.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return { ref: elementRef, isFullscreen, isSupported, toggleFullscreen };
|
||||
}
|
||||
@@ -8566,6 +8566,43 @@ iframe.preview-frame {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 画面右下角的「全屏预览」。压在游戏画面上,所以用深色半透明底 + 白图标:任何游戏配色下都
|
||||
* 看得清,也不在画面中间抢位置。全屏那一格还是它自己(`:fullscreen` 铺满屏幕),所以这枚按钮
|
||||
* 在全屏里照旧可用,用户点它就能退出来。
|
||||
*/
|
||||
.game-run-preview-fullscreen {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgb(255 255 255 / 28%);
|
||||
border-radius: 10px;
|
||||
background: rgb(12 14 20 / 62%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.game-run-preview-fullscreen:hover,
|
||||
.game-run-preview-fullscreen:focus-visible {
|
||||
background: rgb(12 14 20 / 84%);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
/* 全屏里这一格就是整块屏幕:舞台自己的虚线边框与圆角让位给画面。 */
|
||||
.game-run-preview:fullscreen {
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #05070b;
|
||||
}
|
||||
|
||||
.game-run-preview-empty {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
@@ -8588,13 +8625,61 @@ iframe.preview-frame {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/*
|
||||
* 运行页签的底部信息栏。没有内容时整栏不渲染(判据在 `project-development/index.tsx`),
|
||||
* 收起态只剩上面那一行开合按钮——条目卡片的 156px 最小高度不会再变成一片空白色块。
|
||||
*
|
||||
* 卡片只在**有内容**时渲染:一栏也铺满整行,不留半张空位。原先「数值微调」那一栏没有登记表
|
||||
* (前端没有数据源),按用户口径没有功能就先不渲染,它的字段样式(label / input)随这一栏一起
|
||||
* 删掉;登记表接进来时样式与卡片一起回来。
|
||||
*/
|
||||
.game-run-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-run-panels-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.game-run-panels-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #eadbd4;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #62483d;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-run-panels-toggle:hover,
|
||||
.game-run-panels-toggle:focus-visible {
|
||||
border-color: #dfb59f;
|
||||
background: #fdf1ea;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.game-run-panels-toggle-chevron {
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.game-run-panels-toggle-chevron.is-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.game-run-panels-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.game-run-panels > section {
|
||||
.game-run-panels-body > section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
@@ -8616,7 +8701,6 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-run-panels p,
|
||||
.game-run-panels label,
|
||||
.game-run-panels dt,
|
||||
.game-run-panels dd {
|
||||
margin: 0;
|
||||
@@ -8644,24 +8728,6 @@ iframe.preview-frame {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.game-run-panels label {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 88px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-run-panels input {
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #eadbd4;
|
||||
border-radius: 8px;
|
||||
background: #faf7f5;
|
||||
color: #8f7d75;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-workbench-chat {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -10006,7 +10072,7 @@ iframe.preview-frame {
|
||||
min-width: 460px;
|
||||
}
|
||||
|
||||
.game-run-panels {
|
||||
.game-run-panels-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import {
|
||||
AtSign,
|
||||
Box,
|
||||
ChevronDown,
|
||||
Crosshair,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Minus,
|
||||
Music2,
|
||||
PackageOpen,
|
||||
@@ -141,6 +143,7 @@ import {
|
||||
resourceLabelResolver,
|
||||
resourceReferenceCategoryLabel,
|
||||
} from '../../features/project-workspace/resourceReferences';
|
||||
import { useElementFullscreen } from '../../features/project-workspace/useElementFullscreen';
|
||||
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
|
||||
import {
|
||||
type ResourceCanvasAssetGenerationPanelDraft,
|
||||
@@ -1818,6 +1821,9 @@ export default function ProjectDevelopmentView({
|
||||
}: ProjectDevelopmentViewProps) {
|
||||
const [mode, setMode] = useState<WorkbenchMode>('resources');
|
||||
const [runtimeInspectMode, setRuntimeInspectMode] = useState(false);
|
||||
// 运行画面的「全屏预览」:全屏的是画面那一格(`.game-run-preview`),不是整块工作台,
|
||||
// 所以按钮与 ref 都归运行表现层自己持有。
|
||||
const runPreviewFullscreen = useElementFullscreen<HTMLDivElement>();
|
||||
const [resourceBookState, dispatchResourceBook] = useReducer(
|
||||
resourceBookReducer,
|
||||
initialResourceBookState,
|
||||
@@ -3000,6 +3006,23 @@ export default function ProjectDevelopmentView({
|
||||
const selectedResource =
|
||||
canvasResources.find((resource) => resource.id === selectedResourceId) ??
|
||||
null;
|
||||
/**
|
||||
* 运行页签底部信息栏(`.game-run-panels`)的开合。
|
||||
*
|
||||
* 只有一条判据:**有没有内容**——当前是「运行页里选中了一张资源」,「信息展示」渲染的是它
|
||||
* 的只读字段。内容从无到有 / 从有到无都自动跟上(没有内容就自动收起),用户在同一段内容里
|
||||
* 手动收 / 展则一直有效,不会被别的渲染重开。
|
||||
*
|
||||
* 「数值微调」暂时没有登记表(前端没有数据源),按用户口径没有功能就先不渲染这一栏;等
|
||||
* 后端编辑态登记表接进来后,它与它的内容一起进这个判据。
|
||||
*/
|
||||
const runPanelsHaveContent = selectedResource !== null;
|
||||
const [runPanelsExpanded, setRunPanelsExpanded] =
|
||||
useState(runPanelsHaveContent);
|
||||
const runPanelsBodyId = useId();
|
||||
useEffect(() => {
|
||||
setRunPanelsExpanded(runPanelsHaveContent);
|
||||
}, [runPanelsHaveContent]);
|
||||
/**
|
||||
* 画布选中工具栏「编辑标签」的入口判定:
|
||||
* - 选中 1 项:既有的单素材标签编辑(增删标签行为不变);
|
||||
@@ -11002,7 +11025,7 @@ export default function ProjectDevelopmentView({
|
||||
</>
|
||||
) : (
|
||||
<section className="game-run-surface" aria-label="运行表现层">
|
||||
<div className="game-run-preview">
|
||||
<div className="game-run-preview" ref={runPreviewFullscreen.ref}>
|
||||
{embeddedPreviewUrl ? (
|
||||
<LocalGamePreviewFrame
|
||||
title={`${projectName} 游戏运行画面`}
|
||||
@@ -11018,24 +11041,78 @@ export default function ProjectDevelopmentView({
|
||||
<span>点击顶部播放按钮后将在这里直接运行游戏</span>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
全屏预览:贴在画面右下角。**只有画面这一格进全屏**——顶部页签、右侧对话与
|
||||
底部信息栏都不跟着放大,符合「预览画面」而不是「全屏应用」。没有活预览时不渲染,
|
||||
宿主没有 Fullscreen API 时也不渲染(见 `useElementFullscreen`)。
|
||||
*/}
|
||||
{embeddedPreviewUrl && runPreviewFullscreen.isSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-run-preview-fullscreen"
|
||||
aria-label={
|
||||
runPreviewFullscreen.isFullscreen
|
||||
? '退出全屏预览'
|
||||
: '全屏预览'
|
||||
}
|
||||
aria-pressed={runPreviewFullscreen.isFullscreen}
|
||||
title={
|
||||
runPreviewFullscreen.isFullscreen
|
||||
? '退出全屏预览'
|
||||
: '全屏预览游戏画面'
|
||||
}
|
||||
onClick={runPreviewFullscreen.toggleFullscreen}
|
||||
>
|
||||
{runPreviewFullscreen.isFullscreen ? (
|
||||
<Minimize2 size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Maximize2 size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="game-run-panels">
|
||||
<section aria-label="资源信息面板">
|
||||
<header>
|
||||
<Info size={16} aria-hidden="true" />
|
||||
信息展示
|
||||
</header>
|
||||
{selectedResource ? (
|
||||
<ResourceInfoFieldsView resource={selectedResource} />
|
||||
{/*
|
||||
底部信息栏:有内容才存在,没有内容就自动收起(整栏不渲染)。
|
||||
收起态只留下这一行开合按钮,展开态才渲染条目卡片——卡片的 156px 最小高度
|
||||
因此不会再变成一片空白色块。
|
||||
*/}
|
||||
{runPanelsHaveContent ? (
|
||||
<div className="game-run-panels">
|
||||
<div className="game-run-panels-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="game-run-panels-toggle"
|
||||
aria-expanded={runPanelsExpanded}
|
||||
aria-controls={runPanelsBodyId}
|
||||
onClick={() =>
|
||||
setRunPanelsExpanded((current) => !current)
|
||||
}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={`game-run-panels-toggle-chevron${
|
||||
runPanelsExpanded ? '' : ' is-collapsed'
|
||||
}`}
|
||||
/>
|
||||
{runPanelsExpanded ? '收起信息栏' : '展开信息栏'}
|
||||
</button>
|
||||
</div>
|
||||
{runPanelsExpanded ? (
|
||||
<div id={runPanelsBodyId} className="game-run-panels-body">
|
||||
<section aria-label="资源信息面板">
|
||||
<header>
|
||||
<Info size={16} aria-hidden="true" />
|
||||
信息展示
|
||||
</header>
|
||||
{selectedResource ? (
|
||||
<ResourceInfoFieldsView resource={selectedResource} />
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<section aria-label="数值微调面板">
|
||||
<header>
|
||||
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||
数值微调
|
||||
</header>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
{/*
|
||||
|
||||
@@ -3886,6 +3886,11 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
fireEvent.click(runTab);
|
||||
const runInfoPanel = screen.getByLabelText('资源信息面板');
|
||||
expect(resourceInfoFieldRows(runInfoPanel)).toEqual(expectedRows);
|
||||
// 有内容就自动展开;手动收起后条目卡片整体让位,只剩那一行开合按钮。
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起信息栏' }));
|
||||
expect(screen.queryByLabelText('资源信息面板')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开信息栏' }));
|
||||
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
|
||||
// 画布浮层只属于画布:切到运行视图后不再渲染。
|
||||
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
|
||||
});
|
||||
@@ -6456,8 +6461,11 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
|
||||
);
|
||||
expect(screen.queryByLabelText('测试切片控件')).toBeNull();
|
||||
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
|
||||
expect(screen.getByLabelText('数值微调面板')).not.toBeNull();
|
||||
// 底部信息栏没有内容就整栏不渲染:此时既没有选中资源(信息展示没有字段),也没有
|
||||
// 已登记的微调项。留着两张空卡片正是验收现场那半条「空信息栏白占一块高度」。
|
||||
expect(screen.queryByLabelText('资源信息面板')).toBeNull();
|
||||
expect(screen.queryByLabelText('数值微调面板')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '展开信息栏' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import ProjectDevelopmentView from '../src/view/project-development';
|
||||
|
||||
const PREVIEW_URL = 'http://127.0.0.1:4173/';
|
||||
|
||||
function installInvoke() {
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: vi.fn(async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return {
|
||||
resourceIds: [],
|
||||
referenceEdges: [],
|
||||
taskFlows: [],
|
||||
categories: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: args?.expectedProjectId,
|
||||
mode: args?.mode,
|
||||
revision: 0,
|
||||
positions: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
}),
|
||||
},
|
||||
} as unknown as typeof window.__TAURI__;
|
||||
}
|
||||
|
||||
function renderRunView() {
|
||||
installInvoke();
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'run-preview-fullscreen',
|
||||
'运行页全屏预览',
|
||||
);
|
||||
render(
|
||||
<ProjectDevelopmentView
|
||||
projectName={manifest.name}
|
||||
projectPath="/tmp/run-preview-fullscreen"
|
||||
manifest={manifest}
|
||||
attachments={[]}
|
||||
recentRunStatus={null}
|
||||
recentRunStopReason={null}
|
||||
preview={{ status: 'running', url: PREVIEW_URL, port: 4173 }}
|
||||
supervisor={<div>项目总控</div>}
|
||||
onHomeOpen={vi.fn()}
|
||||
onProjectsOpen={vi.fn()}
|
||||
onNotice={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom 完全没有 Fullscreen API(`document.fullscreenEnabled` / `requestFullscreen` /
|
||||
* `fullscreenElement` 都是 undefined),所以这一组用例必须自己把宿主那一份补出来:
|
||||
* 全屏状态、元素身份与 `fullscreenchange` 都按规范最小实现,只用于验按钮的真实行为。
|
||||
*/
|
||||
function installFullscreenHost() {
|
||||
let fullscreenElement: Element | null = null;
|
||||
const requestFullscreen = vi.fn(function (this: Element) {
|
||||
// 这枚桩就是要记录调用方传进来的 `this`:全屏元素身份正是本用例的断言对象(画面那一格)。
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
fullscreenElement = this;
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
return Promise.resolve();
|
||||
});
|
||||
const exitFullscreen = vi.fn(() => {
|
||||
fullscreenElement = null;
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
return Promise.resolve();
|
||||
});
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => fullscreenElement,
|
||||
});
|
||||
Object.defineProperty(document, 'exitFullscreen', {
|
||||
configurable: true,
|
||||
value: exitFullscreen,
|
||||
});
|
||||
Object.defineProperty(Element.prototype, 'requestFullscreen', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: requestFullscreen,
|
||||
});
|
||||
return { requestFullscreen, exitFullscreen };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
Reflect.deleteProperty(document, 'fullscreenElement');
|
||||
Reflect.deleteProperty(document, 'exitFullscreen');
|
||||
Reflect.deleteProperty(Element.prototype, 'requestFullscreen');
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('运行页「全屏预览」', () => {
|
||||
it('点画面右下角那枚按钮就把画面那一格送进全屏,再点退出', async () => {
|
||||
const { requestFullscreen, exitFullscreen } = installFullscreenHost();
|
||||
renderRunView();
|
||||
|
||||
const button = await screen.findByRole('button', { name: '全屏预览' });
|
||||
// 按钮住在画面那一格里(右下角由样式给),全屏的也是那一格——不是整块工作台。
|
||||
const stage = document.querySelector('.game-run-preview');
|
||||
expect(stage).not.toBeNull();
|
||||
expect(button.closest('.game-run-preview')).toBe(stage);
|
||||
expect(button.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
// 全屏元素就是画面那一格:按钮自己也算得出来(状态来自 fullscreenchange,不是乐观值)。
|
||||
expect(document.fullscreenElement).toBe(stage);
|
||||
const exitButton = await screen.findByRole('button', {
|
||||
name: '退出全屏预览',
|
||||
});
|
||||
expect(exitButton.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.click(exitButton);
|
||||
expect(exitFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(await screen.findByRole('button', { name: '全屏预览' })).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('宿主没有 Fullscreen API 时不渲染这枚按钮,而不是留一个点了没反应的入口', async () => {
|
||||
renderRunView();
|
||||
|
||||
await screen.findByTitle('运行页全屏预览 游戏运行画面');
|
||||
expect(screen.queryByRole('button', { name: '全屏预览' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,8 @@
|
||||
|
||||
- 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。iframe 首次适配后发生的真实内容增高或缩短仍必须被接受;仅浏览上下文宽高回灌或内容宽高未变化时保持当前状态,不触发重复渲染。
|
||||
- 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥以根布局 `ResizeObserver` 为主,并在页面可见时每 `500ms` 至多探测 `512` 个元素作为绝对定位溢出的低频兜底;探测截断时不得用部分样本下调尺寸,viewport 耦合的 `100vh / 100% / bottom / right` 布局也不得形成自反馈。相同测量结果去重,不监听整页属性、文本或子节点突变;桥不读取项目正文、不修改 manifest、游戏文件或运行业务状态。桥脚本只能注入到真实 HTML 标签上下文,不能把脚本、样式、模板或注释中的 `</body>` / `</html>` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。
|
||||
- 运行视窗下方继续保留“信息展示”和“数值微调”区域标题及原有面板高度;没有真实资源信息或已登记微调项时,内容区域保持空白,不显示示例字段、默认数值、未载入控件或功能说明,也不得因内容为空压缩两个面板。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。
|
||||
- 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮。
|
||||
- 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是运行页里选中了资源,渲染它的只读字段):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。
|
||||
|
||||
- 数值修改立即写入当前项目的编辑态配置。
|
||||
- 当前已拉起的体验预览和测试切片不热更新;必须重新拉起后才能消费新值。
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏
|
||||
|
||||
- 背景:运行页右下角缺一个把游戏画面放大到整屏的入口;运行视窗下方常驻「信息展示 / 数值微调」两张卡片,没有选中资源时就是两块空白,验收现场提出「没有功能就暂时隐藏」。
|
||||
- 决策一:新增 `useElementFullscreen`(`apps/ai-game-creator-shell/src/features/project-workspace/`),用标准**元素级** Fullscreen API 把**画面那一格**(`.game-run-preview`)送进全屏——不接 Tauri 窗口级全屏,那是把整块工作台连对话栏一起放大的「全屏应用」,不是「全屏预览画面」。入口贴在画面右下角,全屏后仍在原位可点退出;按钮态只认 `fullscreenchange`,Esc、宿主退出都会回落。`requestFullscreen` 不存在或被 `fullscreenEnabled === false` 关掉时整枚入口不渲染,不留点了没反应的按钮。
|
||||
- 决策二:`.game-run-panels` 改成「有内容才存在」的可收起信息栏。判据只有「有没有内容」(当前 = 运行页里选中了资源,信息展示渲染它的只读字段):内容从无到有自动展开、从有到无自动收起,同一段内容里用户手动收 / 展不被别的渲染重开。收起态只剩一行「收起信息栏 / 展开信息栏」按钮,条目卡片的 `156px` 最小高度不再变成空白色块;只有一栏内容时卡片铺满整行。
|
||||
- 决策三:「数值微调」暂时没有登记表(前端没有数据源),按用户口径在没有功能时先不渲染它的区域标题与卡片,对应 `label / input` 声明一并删除;登记表接进来时与内容一起回归。这一条覆盖 PRD §3.4 原先「保留两个面板标题、不得因空内容压缩」的口径,PRD 与技术方案已同步改写。
|
||||
- 验证:新增 `tests/runPreviewFullscreen.test.tsx`(补出 jsdom 缺失的 Fullscreen API:按钮住在画面那一格里、点击 → `requestFullscreen` → 退出全屏,以及宿主没有该 API 时不渲染);AGC 子集补「信息栏有内容自动展开 / 手动收起 / 再展开」,并把「没有内容时运行页仍渲染两张卡片」的旧断言改成整栏不渲染。`apps/ai-game-creator-shell:check:web` 全量通过(`tsc` + 1924 项)、编码检查与 `git diff --check` 通过;并用真实 Chromium 对工作台冒烟:右下角按钮只把画面那一格送进全屏且可退出、选中资源后信息栏自动展开、收起后画面变高、清空选中后整栏消失。
|
||||
|
||||
## 2026-09-23 AGC 发布前先守可运行原型门禁
|
||||
|
||||
- 背景:客户端已经显示“首个可运行原型尚未完成,运行视图暂不可用”,但发布入口仍会先执行用户项目的 `build`,导致未完成原型也进入构建并在后续失败。
|
||||
|
||||
@@ -974,7 +974,7 @@ game-project/
|
||||
- 中间主视窗提供 `resource-overview / resource-editor / ui-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生:静态图片走 `derive + editKind='image-reference'`,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按 manifest 资产功能分类 `category`(`UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`)加末尾独立的「项目版本」栏目分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,画面右下角提供“全屏预览”(元素级 Fullscreen API,只把画面那一格送进全屏;宿主没有该 API 时整枚入口不渲染)。其下的信息栏只在有真实内容(运行页选中资源,渲染只读素材信息)时存在:没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;「数值微调」的登记表尚未接入,因此暂时不渲染它的区域标题与卡片,不渲染预设字段、默认数值、未载入控件或自然语言功能占位。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
|
||||
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
|
||||
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
|
||||
- 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。
|
||||
|
||||
Reference in New Issue
Block a user