Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts
T
suzmii 532c6d51fd
Project CI / Repository checks (push) Successful in 4m5s
Project CI / Frontend tests (push) Successful in 4m48s
Project CI / Backend tests (push) Successful in 6m6s
Project CI / Native shell tests (push) Successful in 15m24s
完善 Game Agent 资源画布与图片精修生成事务 (#181)
## 背景

本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。

当前远端比较基线:

- base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d`
- head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472`
- 相对 base:28 commits、33 files(`+3746 / -1333`)

## 主要改动

### 资源管理分页自由画布

- 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。
- `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。
- 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。
- 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。
- 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。
- 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。

### 图片持续精修与生成事务

- 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。
- 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。
- 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。
- 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。
- 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。

### 合同与文档

- `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。
- 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。
- 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。

## 验证

CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功:

- [x] Repository checks
- [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests)
- [x] Backend tests
- [x] Native shell tests

另有本地定向检查:

- [x] `npm run agc:typecheck`
- [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归
- [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试
- [x] `cargo fmt --check`
- [x] `npm run check:encoding`
- [x] `git diff --check`

## 评审 provenance

- review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。
- review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。
- 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。
- 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。

## 影响范围

- 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。
- 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。
- 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181
Co-authored-by: suzmii <suzmii@qq.com>
Co-committed-by: suzmii <suzmii@qq.com>
2026-08-24 20:42:58 +08:00

236 lines
6.8 KiB
TypeScript

import {
type RefObject,
useCallback,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import type {
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_CANVAS_SECTION_ORDER } from './resourceCanvasLayoutModel';
import {
clampProjectResourceSectionHeight,
clampProjectResourceSectionZoom,
defaultProjectResourceSectionHeight,
type ProjectResourceSectionHeightAction,
projectResourceSectionHeightBounds,
projectResourceSectionHeightKey,
type ProjectResourceSectionZoomAction,
RESOURCE_SECTION_ZOOM_DEFAULT,
RESOURCE_SECTION_ZOOM_MAX,
RESOURCE_SECTION_ZOOM_MIN,
updateProjectResourceSectionHeight,
updateProjectResourceSectionZoom,
} from './resourceSectionHeightModel';
const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [
'dependency',
'type',
];
const RESOURCE_SECTIONS = RESOURCE_CANVAS_SECTION_ORDER;
export type ProjectResourceSectionHeightState = {
height: number;
min: number;
max: number;
defaultHeight: number;
atMin: boolean;
atMax: boolean;
atDefault: boolean;
zoom: number;
atMinZoom: boolean;
atMaxZoom: boolean;
atDefaultZoom: boolean;
};
export function useProjectResourceSectionHeights(input: {
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
canvasRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
}) {
const { canvasRef, enabled, mode, projectId } = input;
const [bounds, setBounds] = useState(() =>
projectResourceSectionHeightBounds(0),
);
const [sessionHeights, setSessionHeights] = useState<Map<string, number>>(
() => new Map(),
);
const [sessionZooms, setSessionZooms] = useState<Map<string, number>>(
() => new Map(),
);
useLayoutEffect(() => {
if (!enabled) {
return undefined;
}
let frameId: number | null = null;
const measure = () => {
frameId = null;
const canvasHeight = canvasRef.current?.clientHeight ?? 0;
if (canvasHeight <= 0) {
return;
}
const nextBounds = projectResourceSectionHeightBounds(canvasHeight);
setBounds((current) =>
current.min === nextBounds.min && current.max === nextBounds.max
? current
: nextBounds,
);
setSessionHeights((current) => {
let next: Map<string, number> | null = null;
for (const layoutMode of RESOURCE_LAYOUT_MODES) {
for (const section of RESOURCE_SECTIONS) {
const key = projectResourceSectionHeightKey({
projectId,
mode: layoutMode,
section,
});
const height = current.get(key);
if (height === undefined) {
continue;
}
const clamped = clampProjectResourceSectionHeight(
height,
nextBounds,
);
if (clamped !== height) {
next ??= new Map(current);
next.set(key, clamped);
}
}
}
return next ?? current;
});
};
const scheduleMeasure = () => {
if (frameId !== null) {
return;
}
frameId = window.requestAnimationFrame(measure);
};
measure();
window.addEventListener('resize', scheduleMeasure);
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
window.removeEventListener('resize', scheduleMeasure);
};
}, [canvasRef, enabled, projectId]);
const sectionStates = useMemo(() => {
const defaultHeight = defaultProjectResourceSectionHeight(bounds);
return new Map(
RESOURCE_SECTIONS.map((section) => {
const key = projectResourceSectionHeightKey({
projectId,
mode,
section,
});
const height = clampProjectResourceSectionHeight(
sessionHeights.get(key) ?? defaultHeight,
bounds,
);
const zoom = clampProjectResourceSectionZoom(
sessionZooms.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
);
return [
section,
{
height,
min: bounds.min,
max: bounds.max,
defaultHeight,
atMin: height === bounds.min,
atMax: height === bounds.max,
atDefault: height === defaultHeight,
zoom,
atMinZoom: zoom === RESOURCE_SECTION_ZOOM_MIN,
atMaxZoom: zoom === RESOURCE_SECTION_ZOOM_MAX,
atDefaultZoom: zoom === RESOURCE_SECTION_ZOOM_DEFAULT,
} satisfies ProjectResourceSectionHeightState,
] as const;
}),
);
}, [bounds, mode, projectId, sessionHeights, sessionZooms]);
const updateSectionHeight = useCallback(
(
section: ProjectResourceCanvasSection,
action: ProjectResourceSectionHeightAction,
) => {
const key = projectResourceSectionHeightKey({ projectId, mode, section });
setSessionHeights((current) => {
const currentHeight = clampProjectResourceSectionHeight(
current.get(key) ?? defaultProjectResourceSectionHeight(bounds),
bounds,
);
const nextHeight = updateProjectResourceSectionHeight(
currentHeight,
action,
bounds,
);
if (nextHeight === currentHeight) {
return current;
}
const next = new Map(current);
next.set(key, nextHeight);
return next;
});
},
[bounds, mode, projectId],
);
const setSectionZoom = useCallback(
(section: ProjectResourceCanvasSection, zoom: number) => {
const key = projectResourceSectionHeightKey({ projectId, mode, section });
const nextZoom = clampProjectResourceSectionZoom(zoom);
setSessionZooms((current) => {
const currentZoom = clampProjectResourceSectionZoom(
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
);
if (nextZoom === currentZoom) {
return current;
}
const next = new Map(current);
next.set(key, nextZoom);
return next;
});
},
[mode, projectId],
);
const updateSectionZoom = useCallback(
(
section: ProjectResourceCanvasSection,
action: ProjectResourceSectionZoomAction,
) => {
const key = projectResourceSectionHeightKey({ projectId, mode, section });
setSessionZooms((current) => {
const currentZoom = clampProjectResourceSectionZoom(
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
);
const nextZoom = updateProjectResourceSectionZoom(currentZoom, action);
if (nextZoom === currentZoom) {
return current;
}
const next = new Map(current);
next.set(key, nextZoom);
return next;
});
},
[mode, projectId],
);
return {
sectionStates,
setSectionZoom,
updateSectionHeight,
updateSectionZoom,
};
}