AGC 资源画布:定位到素材后把视口居中到该卡
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m38s
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (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 / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m38s
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (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 / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
- 根因:`handleResourceSelect` 只改选中、不移动画布,而这张画布是 transform 平移的、资源卡不在任何滚动容器里;既有的 `card.scrollIntoView()` 碰不到滚动祖先,卡在视口外时用户看到的就是「定位过去了但依然见不到素材」 - 新增 `centerResourceCanvasOnResource`:读该卡在当前布局位置表里的 x/y 与卡片尺寸,按 `canvasSize / 2 − 卡中心 × scale` 求视口平移量;缩放保持不变(定位不改用户缩放预期,与 `ensureResourceBookContentVisible` 同口径) - 聚焦链在「选中新资源」那一步之后调用它;`scrollIntoView` 保留(栏目页仍有带滚动条的祖先) - 用例:新增「centers the canvas viewport on the located asset so it is actually visible」,用渲染进 DOM 的真实画布尺寸断言 `viewportX + 卡中心 × scale = 画布中心` - 文档:decision-log 补「失去焦点即收起」与「定位必须让素材真的可见」两条决策;AGC 入口矩阵同步自动收起、入口顺序与定位居中口径 验证:tsc --noEmit 通过;appSurface 全量 441 条通过;变异验证:把定位链里的居中调用摘掉,新用例变红(450 vs 400,正好是画布宽 800 的一半与旧视口之差)
This commit is contained in:
@@ -4650,6 +4650,58 @@ export default function ProjectDevelopmentView({
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* 把画布视口居中到某张资源卡:定位链在「选中了新资源」之后还必须让用户**看得见**它。
|
||||
*
|
||||
* 这张画布是 transform 平移的,卡不在 DOM 滚动容器里,`scrollIntoView()` 只能碰到既有的
|
||||
* 带滚动条祖先,卡在视口外时它不会把卡带回来——所以视口必须由这里显式改。缩放保持不变
|
||||
* (只平移),与 `ensureResourceBookContentVisible` 的既有口径一致:定位不改用户的缩放预期。
|
||||
*/
|
||||
const centerResourceCanvasOnResource = useCallback(
|
||||
(resourceId: string) => {
|
||||
const category: ResourceBookTarget | null = resourceBookOpensAllResources
|
||||
? RESOURCE_BOOK_ALL_TARGET
|
||||
: activePageCategory;
|
||||
if (!category || resourceBookView !== 'child') {
|
||||
return;
|
||||
}
|
||||
const position = resourcePositionById.get(resourceId);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
const cardSize = resourceCardSizeByResourceId.get(resourceId);
|
||||
if (!cardSize) {
|
||||
return;
|
||||
}
|
||||
const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current);
|
||||
if (canvasSize.width <= 0 || canvasSize.height <= 0) {
|
||||
return;
|
||||
}
|
||||
const viewport = normalizeResourceBookViewport(
|
||||
category === RESOURCE_BOOK_ALL_TARGET
|
||||
? resourceBookAllViewportRef.current
|
||||
: resourceCanvasViewportRef.current,
|
||||
);
|
||||
setResourceCanvasViewport(category, {
|
||||
scale: viewport.scale,
|
||||
x:
|
||||
canvasSize.width / 2 -
|
||||
(position.x + cardSize.width / 2) * viewport.scale,
|
||||
y:
|
||||
canvasSize.height / 2 -
|
||||
(position.y + cardSize.height / 2) * viewport.scale,
|
||||
});
|
||||
},
|
||||
[
|
||||
activePageCategory,
|
||||
resourceBookOpensAllResources,
|
||||
resourceBookView,
|
||||
resourceCardSizeByResourceId,
|
||||
resourcePositionById,
|
||||
setResourceCanvasViewport,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space' && !event.repeat) {
|
||||
@@ -5748,10 +5800,14 @@ export default function ProjectDevelopmentView({
|
||||
pendingResourceFocusRef.current = null;
|
||||
setResourceWorkbenchNotice('');
|
||||
setHiddenCommittedResourceId(null);
|
||||
// 既有的 scrollIntoView 只对带滚动条的祖先有效;这张画布靠 transform 平移,视口必须显式居中,
|
||||
// 否则「定位过去了但素材还在屏幕外」——用户看到的仍是没定位。
|
||||
centerResourceCanvasOnResource(intent.resourceId);
|
||||
card.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
handleResourceSelect(intent.resourceId);
|
||||
}, [
|
||||
activePageCategory,
|
||||
centerResourceCanvasOnResource,
|
||||
dependencyLayout.layout.positions,
|
||||
dependencyLayout.settled,
|
||||
handleResourceSelect,
|
||||
|
||||
@@ -11198,6 +11198,91 @@ export function registerProjectAgentStatusTests() {
|
||||
expect(screen.queryByText('正在定位生成的素材…')).toBeNull();
|
||||
}, 20_000);
|
||||
|
||||
it('centers the canvas viewport on the located asset so it is actually visible', async () => {
|
||||
// 「定位到素材」只把卡选中不够:这张画布是 transform 平移的,卡在视口外时
|
||||
// `scrollIntoView()` 碰不到任何滚动祖先,用户看到的就是"定位过去了但依然见不到素材"。
|
||||
const panel = await renderGenerationLocateView({
|
||||
projectId: 'workbench-locate-centers-viewport',
|
||||
projectPath: '/tmp/workbench-locate-centers-viewport',
|
||||
openCategory: '角色与对象',
|
||||
assetId: 'locate-centers-asset',
|
||||
ledgerRecords: [
|
||||
completedGenerationRecord({
|
||||
taskId: 'task-locate-centers',
|
||||
assetId: 'locate-centers-asset',
|
||||
projectId: 'workbench-locate-centers-viewport',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// 先把画布量出尺寸:jsdom 里 clientWidth/Height 恒为 0,不量就会静默走"没尺寸不居中"那条分支。
|
||||
// 居中使用的是栏目页画布(`.game-resource-page-canvas`),不是外层 `[aria-label]` 容器。
|
||||
const canvas = (await screen.findByLabelText(
|
||||
'资源依赖视图',
|
||||
)) as HTMLDivElement;
|
||||
const pageCanvas = canvas.querySelector<HTMLElement>(
|
||||
'.game-resource-page-canvas',
|
||||
);
|
||||
expect(pageCanvas).not.toBeNull();
|
||||
Object.defineProperties(pageCanvas!, {
|
||||
clientWidth: { configurable: true, get: () => 800 },
|
||||
clientHeight: { configurable: true, get: () => 600 },
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
|
||||
);
|
||||
|
||||
const card = await waitFor(() => {
|
||||
const element = document.querySelector<HTMLElement>(
|
||||
'.game-resource-card[data-resource-card-id="asset:locate-centers-asset"]',
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.game-resource-card-select[data-resource-id="asset:locate-centers-asset"][aria-pressed="true"]',
|
||||
),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
|
||||
const readPixels = (name: string) =>
|
||||
Number(
|
||||
/([\d.-]+)px/u.exec(card.style.getPropertyValue(name))?.[1] ?? 'NaN',
|
||||
);
|
||||
const centerX =
|
||||
readPixels('--resource-x') + readPixels('--resource-card-width') / 2;
|
||||
const centerY =
|
||||
readPixels('--resource-y') + readPixels('--resource-card-height') / 2;
|
||||
expect(Number.isFinite(centerX)).toBe(true);
|
||||
expect(Number.isFinite(centerY)).toBe(true);
|
||||
|
||||
const world = document.querySelector<HTMLElement>(
|
||||
'.game-resource-page-canvas[data-resource-section-scroll="character"] [data-resource-viewport]',
|
||||
);
|
||||
const [viewportX, viewportY, viewportScale] = (
|
||||
world?.getAttribute('data-resource-viewport') ?? ''
|
||||
)
|
||||
.split(',')
|
||||
.map(Number);
|
||||
expect(viewportScale).toBeGreaterThan(0);
|
||||
// 视口平移量必须让卡片中心落在画布中心:`viewportX + centerX × scale = 画布宽 / 2`。
|
||||
const canvasWidth = pageCanvas!.clientWidth;
|
||||
const canvasHeight = pageCanvas!.clientHeight;
|
||||
expect(canvasWidth).toBeGreaterThan(0);
|
||||
expect(canvasHeight).toBeGreaterThan(0);
|
||||
expect(viewportX! + centerX * viewportScale!).toBeCloseTo(
|
||||
canvasWidth / 2,
|
||||
0,
|
||||
);
|
||||
expect(viewportY! + centerY * viewportScale!).toBeCloseTo(
|
||||
canvasHeight / 2,
|
||||
0,
|
||||
);
|
||||
}, 20_000);
|
||||
|
||||
it('surfaces the existing clear-search action when the generated asset is filtered out', async () => {
|
||||
const panel = await renderGenerationLocateView({
|
||||
projectId: 'workbench-locate-hidden',
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
- 背景(验收人在真机上连报三条):① 提交按钮上渲染了后端 `phaseDetail`,「生成图片」的主按钮变成写着「排队中。」的状态胶囊;② 提交后提交面板不关、一直占着屏幕等生成,用户原话「不要显示排队中,点生成直接把窗口藏起来啊,你留个窗口意义何在」;③ 任务进度面是一个工具条按钮 + 非模态浮层,跟网页端美术画布的任务侧栏不是一个形态,用户原话「你把美术画布的照抄过来都不会吗」;④ 「定位到素材」点了没反应,提示条永久停在「正在定位生成的素材…」。
|
||||
- 决策(提交面板):**点「生成」即同步关闭面板**——不等 IPC、不等排队、不等生成;面板内**不出现**任何阶段文案(主按钮文案恒为动作名)。**只有「点击瞬间就失败」**(后端校验 / 权限拒绝 / start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在任务侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消(请求挂在任务与项目内账本上,不挂在面板生命周期上)。
|
||||
- 决策(进度面形态):改为**常驻画布的可折叠任务侧栏**(对齐网页端 `ImageCanvasTaskSidebarView`)——展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 提示「仅显示最近 N 条」),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其 border-top 分割线已删除);折叠即整块让出画布、**不留贴边把手**;每项显示状态徽标 / 后端阶段文案 / 已耗时(前端 1s 计时)/ 素材名 + 「定位到素材」;侧栏非模态(不铺遮罩、不做焦点陷阱、不进模态遮挡判据),工具栏入口按钮是**唯一**开合口、常驻并显示 `生成任务 · N`,提交受理后自动展开。**原先的非模态浮层形态已删除,不留平行入口。**
|
||||
- 决策(进度面形态):改为**常驻画布的可折叠任务侧栏**(对齐网页端 `ImageCanvasTaskSidebarView`)——展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 提示「仅显示最近 N 条」),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其 border-top 分割线已删除);折叠即整块让出画布、**不留贴边把手**;每项显示状态徽标 / 后端阶段文案 / 已耗时(前端 1s 计时)/ 素材名 + 「定位到素材」;侧栏非模态(不铺遮罩、不做焦点陷阱、不进模态遮挡判据),工具栏入口按钮是**唯一**开合口、常驻(资源管理 / 运行两个页签都在)并显示 `生成任务 · N`,提交受理后自动展开。**原先的非模态浮层形态已删除,不留平行入口。**
|
||||
- 决策(侧栏失去焦点即收起):展开时挂 document 级 `pointerdown` 捕获监听,点在侧栏内部与工具条那枚开合按钮以外的地方即收起。两处必须排除——**侧栏内部**(点任务卡、点「定位到素材」不能收起侧栏)与**开合按钮本身**(它自己负责 toggle,若也被判成「点外部」就会先收起再被 toggle 打开,表现为按钮失灵;按钮带 `data-resource-generation-task-toggle` 标记供排除)。用 `pointerdown` 而不是 `click`:画布空白处的左键 pointerdown 会 `preventDefault()`,document 上的 click 收不到那一次点击。
|
||||
- 决策(定位必须让素材真的可见):`handleResourceSelect` 只改选中、不会移动画布,而这张画布是 **transform 平移**的、资源卡不在任何滚动容器里——`card.scrollIntoView()` 碰不到滚动祖先,卡在视口外时"定位过去了但依然见不到素材"。所以聚焦链在选中之后必须**显式把画布视口居中到该卡**(`centerResourceCanvasOnResource`:读该卡在当前位置表里的 `x/y` 与 `resourceCardSizeByResourceId` 的尺寸,按 `canvasSize / 2 − 卡中心 × scale` 求平移量,**缩放保持不变**,与 `ensureResourceBookContentVisible` 的既有口径一致:定位不改用户的缩放预期)。`scrollIntoView` 一并保留(栏目页仍有带滚动条的祖先)。
|
||||
- 决策(侧栏位置):挂在画布**左侧、标题栏之下、工具栏之上**,宽 300px、**覆盖式**(不 reflow 挤窄画布视口)。理由:右侧已被「智能创作」对话面板占用、顶部是栏目标题栏、底部是栏目工具栏;覆盖式不触碰画布视口数学与资源卡排布,收起即完全让出画布。若产品要求「画布被挤窄」的 flex 兄弟列形态(网页端是那种),需要改 `game-resource-book-manager` 那段布局并单独排期。
|
||||
- 决策(定位终局化):根因是聚焦 effect 的依赖全是画布自身状态,**手动点定位不改其中任何一项** → effect 不重跑、`pendingResourceFocusRef` 无人消费、提示条永久停在中转文案。修法:新增聚焦请求序号并加入 effect 依赖;handler 重写为「能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给『素材已不在项目里 / 已登记但尚未同步』的结论;挂 intent 后推进序号 + **3 秒有界兜底**;intent 被判 invalid 时也给『定位请求已失效』」,并修掉「已聚焦过」提前返回分支不清提示的同类问题(自动落卡那条链同源)。
|
||||
- 影响范围:`src/features/resource-canvas/{ResourceCanvasAssetGenerationPanelView.tsx,ResourceCanvasGenerationPanelView.tsx,ResourceCanvasAssetGenerationTasksPanelView.tsx,resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts}`、`src/view/project-development/index.tsx`,测试 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationTasksPanel.test.tsx,resourceCanvasBottomToolbar.test.tsx,appSurface/project-development.suite.ts}`。**未改** IPC 形状与 Rust 生成通道、未改本地排队语义(仍单条在途)、未改 `packages/**`。
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
- **本地排队**:AGC 本地 durable 输出槽按**精确动作指纹**分槽(`run_id = slot-<sha256(动作材料)>`,材料为 prompt / outputPath / 比例 / 尺寸 / assetKind / assetLabel / replaceExisting / requireSlices;不同 prompt 或素材名各自独立成槽,不再按 `outputPath` 共用;旧 `{outputPath,requireSlices}` 槽账本由同一精确动作懒迁移)。因此后端已具备并行能力,但**本批前端仍按「同一时刻只派发一条」排队**——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批。所以第二条提交停在**前端本地队列**里(不调用提交 IPC,阶段显示本地排队的「排队中。」),第一条终态后由同一条循环自动补发;判据是「存在 `dispatched && 未终态` 的任务时不派发下一条」。
|
||||
- **阶段文案只来自后端,且只出现在任务侧栏**:派发之后由任务侧栏渲染后端账本给的 `phaseDetail`(「排队中。」/「正在生成。」/「生成已完成。」/失败原因),提交面板已关闭、不渲染任何阶段文案;前端不拼阶段、不做百分比。
|
||||
- **「生成任务」是常驻画布的可折叠侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除);折叠即整块让出画布、**不留贴边把手**;位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口)。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、**不参与** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的遮挡判据),提交受理后自动展开;工具栏入口按钮是**唯一**开合口、**两个页签(资源管理 / 运行)下都常驻**,并显示 `生成任务 · N`。每项的「定位到素材」**每次点击都终局化**(含跨栏目先切栏目、不在投影里给结论、3 秒有界兜底),不允许提示条永久停在「正在定位生成的素材…」。
|
||||
- **「生成任务」是常驻画布的可折叠侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除);折叠即整块让出画布、**不留贴边把手**;**失去焦点(点侧栏外部,不含那枚开合按钮本身)即自动收起**;位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口)。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、**不参与** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的遮挡判据),提交受理后自动展开;工具栏入口按钮是**唯一**开合口、**两个页签(资源管理 / 运行)下都常驻**、排在「依赖 / 类型」排列方式**之前**(动作在前、排列方式收行尾),并显示 `生成任务 · N`。每项的「定位到素材」**每次点击都终局化**(含跨栏目先切栏目、不在投影里给结论、3 秒有界兜底),不允许提示条永久停在「正在定位生成的素材…」;定位成功后必须**把画布视口居中到该卡**——这张画布是 transform 平移的,`scrollIntoView()` 碰不到滚动祖先,只改选中会让用户"定位过去了但依然见不到素材"。
|
||||
|
||||
## 5. 参数口径与复用程度
|
||||
|
||||
|
||||
Reference in New Issue
Block a user