资源画布交互 1:1 复刻美术画布:点卡选中并浮出工具条,移除资源详情面板与全屏编辑路由

新增 features/resource-canvas:资源卡到美术画布 CanvasLayer 的适配、工具条动作可用性判定、C5 当前版本绑定判定、快速编辑草稿工厂与 AGC 宿主 chrome 样式

资源卡点击改为选中(Shift/Ctrl/Meta 多选),选中后在卡片旁复用 ImageCanvasSelectedLayerToolbarView 浮出工具条

快速编辑复用 ImageCanvasQuickEditPanelView 浮层面板,先调 normalize_local_project_raster_resource 正规化任务产物,再走 derive_local_project_resource(editKind=image-reference) 产出新素材,失败重试复用同一 operationId/idempotencyKey

删除 resourceEditorRoute 全链路(ResourceEditSurface 路由、openResourceEditor、handleResourceCardOpenEditor、cancelResourceEditor、submitResourceEdit)与资源详情面板(focusedResource 状态/引用/浮层 JSX/CSS 断言)

分类与标签入口搬到选中工具条;UI 资源保留「UI 编辑器」入口并从工具条打开

移除 apps/ai-game-creator-shell/scripts/check-config.mjs 中 normalize_local_project_raster_resource 的过渡 allowlist 条目

为共享工具条组件新增 opt-in 的 supportedActions/extraActions(默认 null/undefined,网页端行为不变)

AGC vite-env.d.ts 补 wx/ReactNativeWebView/WeixinJSBridge 跨端全局声明;hostBridge fail 回调补显式 unknown 类型

测试:资源卡查询 helper 由资源详情按钮改为选中按钮,清理详情面板断言,画布空白左键改为框选后平移用例改用中键
This commit is contained in:
2026-09-10 15:19:17 +08:00
parent 912254afa0
commit bd36587e0a
15 changed files with 1390 additions and 1315 deletions
@@ -111,7 +111,6 @@ const allowedUncalledTauriCommands = [
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
'normalize_local_project_raster_resource',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
@@ -0,0 +1,241 @@
/*
* 共享美术画布组件(ImageCanvasSelectedLayerToolbarView / ImageCanvasQuickEditPanelView
* 的 AGC 宿主 chrome。
*
* 这些 `image-canvas-editor__*` 类名在网页端由全局样式表 src/index.css 提供;AGC 是独立
* 应用、不引入那张整站样式表,因此在这里按实际用到的类补一套宿主样式:定位、浮层层级、
* 按钮与面板外观。组件本身与交互语义仍全部复用美术画布的实现。
*/
/* 主题变量:与网页端 .image-canvas-editor 作用域下的一组变量保持一致。 */
.game-resource-canvas-toolbar-host,
.image-canvas-editor__portal-theme,
.image-canvas-editor__portal-menu {
--image-canvas-brand-accent: var(--platform-accent, #c7653d);
--image-canvas-brand-accent-strong: #6f2f21;
--image-canvas-brand-fill: var(
--platform-button-primary-fill,
linear-gradient(135deg, #df7f40, #b95d3a)
);
--image-canvas-brand-text-on-fill: var(
--platform-button-primary-text,
#fffaf5
);
--image-canvas-brand-border: rgba(204, 117, 76, 0.34);
--image-canvas-brand-border-strong: rgba(199, 101, 61, 0.64);
--image-canvas-brand-border-soft: rgba(226, 203, 184, 0.82);
--image-canvas-brand-soft: rgba(234, 204, 179, 0.28);
--image-canvas-brand-soft-strong: rgba(238, 208, 183, 0.46);
--image-canvas-brand-shadow: rgba(182, 98, 63, 0.16);
--image-canvas-brand-focus-ring: rgba(204, 117, 76, 0.18);
}
/* 选中工具条:贴在资源卡正上方浮出,层级高于卡片与画布标题栏。 */
.game-resource-canvas-toolbar-host
.image-canvas-editor__floating-toolbar {
position: absolute;
z-index: 60;
display: inline-flex;
max-width: min(560px, 92vw);
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
border: 1px solid var(--image-canvas-brand-border-soft);
border-radius: 0.5rem;
background: #ffffff;
padding: 0.28rem;
box-shadow: 0 18px 34px rgba(15, 23, 42, 0.18);
transform: translate(-50%, -100%);
pointer-events: auto;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__floating-toolbar
button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--image-canvas-brand-border-soft);
border-radius: 0.375rem;
background: #ffffff;
color: #334155;
cursor: pointer;
transition:
transform 160ms ease,
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__floating-toolbar
button:hover:not([disabled]) {
transform: translateY(-1px);
border-color: var(--image-canvas-brand-border-strong);
background: var(--image-canvas-brand-soft);
color: var(--image-canvas-brand-accent-strong);
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__floating-toolbar
button[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__floating-toolbar
button.image-canvas-editor__floating-toolbar-text-button {
width: auto;
min-width: 0;
gap: 0.35rem;
padding: 0.34rem 0.62rem;
font-size: 0.74rem;
font-weight: 850;
white-space: nowrap;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__floating-toolbar-divider {
display: block;
width: 1px;
height: 1.1rem;
background: var(--image-canvas-brand-border-soft);
}
/* 快速编辑浮层面板:锚在资源卡正下方。 */
.game-resource-canvas-toolbar-host .image-canvas-editor__generation-composer {
position: absolute;
z-index: 70;
display: flex;
width: min(360px, 92vw);
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--image-canvas-brand-border-soft);
border-radius: 0.75rem;
background: #ffffff;
box-shadow: 0 22px 40px rgba(15, 23, 42, 0.22);
transform: translateX(-50%);
pointer-events: auto;
}
.game-resource-canvas-toolbar-host .image-canvas-editor__generation-prompt {
width: 100%;
min-height: 4.5rem;
resize: none;
border: 1px solid var(--image-canvas-brand-border-soft);
border-radius: 0.5rem;
padding: 0.5rem 0.6rem;
background: #fffdfa;
color: #1f2937;
font-size: 0.82rem;
line-height: 1.5;
}
.game-resource-canvas-toolbar-host .image-canvas-editor__reference-strip {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__generation-composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.game-resource-canvas-toolbar-host .image-canvas-editor__option-cluster {
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
.game-resource-canvas-toolbar-host .image-canvas-editor__option-popover-anchor {
position: relative;
display: inline-flex;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__option-popover {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 80;
min-width: 12rem;
border: 1px solid var(--image-canvas-brand-border-soft);
border-radius: 0.6rem;
background: #ffffff;
padding: 0.5rem;
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.2);
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__option-popover-sections {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__option-popover-items {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__option-popover-choice {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.28rem 0.5rem;
border: 1px solid var(--image-canvas-brand-border-soft);
border-radius: 0.375rem;
background: #fffdfa;
color: #334155;
font-size: 0.74rem;
cursor: pointer;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__generation-submit {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.9rem;
border: 1px solid transparent;
border-radius: 0.5rem;
background: var(--image-canvas-brand-fill);
color: var(--image-canvas-brand-text-on-fill);
font-size: 0.78rem;
font-weight: 850;
cursor: pointer;
}
.game-resource-canvas-toolbar-host
.image-canvas-editor__generation-submit:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.game-resource-canvas-toolbar-host .image-canvas-editor__generate-status {
margin: 0;
font-size: 0.74rem;
color: #8c6252;
}
@media (max-width: 720px) {
.game-resource-canvas-toolbar-host
.image-canvas-editor__floating-toolbar {
max-width: 94vw;
}
.game-resource-canvas-toolbar-host .image-canvas-editor__generation-composer {
width: 94vw;
}
}
@@ -0,0 +1,15 @@
import type { CanvasLayer } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
import type { QuickEditPanelState } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
import { createQuickEditPanelDraft } from '../../../../../src/components/image-editor/ImageCanvasGenerationDialogModel';
/**
* AGC 资源卡快速编辑面板的初始草稿。
*
* 直接在网页端美术画布的草稿工厂上包一层,保证两个画布的默认模型、比例与尺寸
* 取值完全相同;AGC 侧不再自带第二套默认值。
*/
export function createResourceQuickEditPanelDraft(
sourceLayer: CanvasLayer,
): QuickEditPanelState {
return createQuickEditPanelDraft(sourceLayer, {});
}
@@ -0,0 +1,167 @@
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type {
CanvasAssetKind,
CanvasLayer,
CanvasMediaType,
} from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
import type { ImageCanvasSelectedToolbarAction } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView';
import { canonicalProjectedResourceMediaType } from '../../view/project-development/resourceEditModel';
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
/** 快速编辑与改造都走 `derive_local_project_resource`,只接受这三种栅格图片格式。 */
const RESOURCE_RASTER_MEDIA_TYPES = new Set([
'image/png',
'image/jpeg',
'image/webp',
]);
const CANVAS_ASSET_KINDS = new Set<string>([
'audio',
'spec',
'character',
'character-animation',
'icon',
'icon-spritesheet',
'icon-spec',
'publication-material',
'ui-design',
'video',
'sound-effect',
'background-music',
'scene',
]);
export function resourceCanvasMediaType(
resource: Pick<ProjectResource, 'mediaType' | 'category' | 'path'>,
): CanvasMediaType | undefined {
const mediaType = canonicalProjectedResourceMediaType({
mediaType: resource.mediaType,
path: resource.path,
} as ProjectResource);
if (mediaType.startsWith('image/')) {
return 'image';
}
if (mediaType.startsWith('video/')) {
return 'video';
}
if (mediaType.startsWith('audio/') || resource.category === 'audio') {
return 'audio';
}
return undefined;
}
export function resourceCanvasAssetKind(
resource: Pick<ProjectResource, 'subtype'>,
): CanvasAssetKind | null {
return CANVAS_ASSET_KINDS.has(resource.subtype)
? (resource.subtype as CanvasAssetKind)
: null;
}
export function isResourceRasterImage(
resource: Pick<ProjectResource, 'mediaType' | 'category' | 'path'>,
) {
return RESOURCE_RASTER_MEDIA_TYPES.has(
canonicalProjectedResourceMediaType({
mediaType: resource.mediaType,
path: resource.path,
} as ProjectResource),
);
}
/**
* 任务产物类型的资源只有先被正规化成正式 manifest 素材,才能作为派生源。
*
* 与 Rust `normalize_local_project_raster_resource_at` 的门禁对齐:必须是已完成
* 任务登记在 artifacts 里的栅格图片产物。
*/
export function canNormalizeResourceIntoManifestAsset(
manifest: GameCreationAppManifest,
resource: Pick<
ProjectResource,
'manifestAssetId' | 'producerTaskId' | 'path' | 'mediaType' | 'category'
>,
) {
if (resource.manifestAssetId !== null) {
return false;
}
const producerTaskId = resource.producerTaskId?.trim();
if (!producerTaskId || !isResourceRasterImage(resource)) {
return false;
}
return manifest.tasks.some(
(task) =>
task.id === producerTaskId &&
task.status === 'completed' &&
task.artifacts.includes(resource.path),
);
}
export function resolveResourceCanvasToolbarActions(
manifest: GameCreationAppManifest,
resource: ProjectResource,
): ReadonlySet<ImageCanvasSelectedToolbarAction> {
const supported = new Set<ImageCanvasSelectedToolbarAction>();
const mediaType = resourceCanvasMediaType(resource);
const canDeriveFromResource =
resource.manifestAssetId !== null ||
canNormalizeResourceIntoManifestAsset(manifest, resource);
if (mediaType === 'image') {
if (isResourceRasterImage(resource) && canDeriveFromResource) {
supported.add('quick-edit');
}
if (canDeriveFromResource) {
supported.add('redraw');
}
if (
resourceCanvasAssetKind(resource) === 'character' &&
canDeriveFromResource
) {
supported.add('character-animation');
}
return supported;
}
if (
(mediaType === 'video' || mediaType === 'audio') &&
canDeriveFromResource
) {
supported.add('redraw');
}
return supported;
}
/**
* 把资源卡投影成美术画布 `CanvasLayer`,让工具条、命中测试与几何判定共用同一套语义。
*/
export function projectResourceToCanvasLayer({
resource,
x,
y,
width,
height,
src = '',
}: {
resource: ProjectResource;
x: number;
y: number;
width: number;
height: number;
src?: string;
}): CanvasLayer {
return {
id: resource.id,
resourceId: resource.id,
title: resource.label,
src,
mediaType: resourceCanvasMediaType(resource),
x,
y,
width,
height,
originalWidth: width,
originalHeight: height,
zIndex: 0,
sourceType: 'uploaded',
assetKind: resourceCanvasAssetKind(resource),
};
}
@@ -0,0 +1,35 @@
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
/**
* C5 卡片边框:一个资源是否正在被「当前版本」使用。
*
* 当前版本 = manifest `versions[]` 的最后一个版本。版本绑定是资源记录的权威口径,
* `slotId` 恒等映射为 `asset:{assetId}``resourceId` 指向 manifest 资产 ID。
* 版本切换状态接入后,只需把 `manifest` 换成「当前选中的版本」即可复用同一判定。
*/
export function currentVersionResourceBindingIds(
manifest: Pick<GameCreationAppManifest, 'versions'>,
): Set<string> {
const currentVersion = (manifest.versions ?? []).at(-1);
if (!currentVersion) {
return new Set();
}
return new Set(
currentVersion.resourceBindings.flatMap((binding) =>
binding.slotId === `asset:${binding.resourceId}`
? [binding.resourceId]
: [],
),
);
}
export function isResourceUsedByCurrentVersion(
resource: Pick<ProjectResource, 'manifestAssetId'>,
currentVersionBindingIds: ReadonlySet<string>,
) {
return (
resource.manifestAssetId !== null &&
currentVersionBindingIds.has(resource.manifestAssetId)
);
}
+1
View File
@@ -1,5 +1,6 @@
import '@genarrative/image-canvas-react/styles.css';
import './styles.css';
import './features/resource-canvas/resourceCanvasChrome.css';
import React from 'react';
import { createRoot } from 'react-dom/client';
+11 -3
View File
@@ -6738,7 +6738,7 @@ iframe.preview-frame {
min-height: var(--resource-card-height);
padding: 0;
overflow: visible;
border: 1px solid #eadbd4;
border: 0;
border-radius: 12px;
background: #fff;
color: #4e382f;
@@ -6768,6 +6768,14 @@ iframe.preview-frame {
box-shadow: 0 8px 22px rgb(195 105 62 / 15%);
}
/* C5 卡片边框:只有被当前版本绑定使用的素材带边框,其余资源卡无边框。 */
.game-resource-card.is-current-version {
border: 1px solid #d87342;
box-shadow:
0 6px 18px rgb(96 62 47 / 10%),
0 0 0 2px rgb(216 115 66 / 14%);
}
.game-resource-card.is-relation-version-binding {
border-color: #d87342;
box-shadow:
@@ -6851,7 +6859,7 @@ iframe.preview-frame {
transform-origin: bottom right;
}
.game-resource-card-open {
.game-resource-card-select {
position: absolute;
z-index: 1;
inset: 0;
@@ -6865,7 +6873,7 @@ iframe.preview-frame {
touch-action: manipulation;
}
.game-resource-card-open:focus-visible {
.game-resource-card-select:focus-visible {
outline: 3px solid rgb(213 123 81 / 55%);
outline-offset: -4px;
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -15,4 +15,23 @@ interface Window {
) => Promise<() => void>;
};
};
// 资源画布复用网页端美术画布的组件,这条依赖链是明确的跨端耦合:
// ImageCanvasSelectedLayerToolbarView → ImageCanvasGenerationModel →(值导入
// ApiClientError)→ 主仓 src/services/apiClient.ts → src/services/host-bridge/hostBridge.ts。
// hostBridge 读取的全局只在主仓 `src/vite-env.d.ts` 里声明过,AGC 侧必须补同名字段
// 才能过 strict 检查。以后往 AGC 引 `src/components/image-editor/` 的组件,都要检查这条链。
ReactNativeWebView?: {
postMessage?: (message: string) => void;
};
wx?: {
miniProgram?: {
navigateTo?: (options: {
url: string;
success?: (result?: unknown) => void;
fail?: (error: { errMsg?: string }) => void;
}) => void;
postMessage?: (message: unknown) => void;
};
};
WeixinJSBridge?: unknown;
}
@@ -332,35 +332,35 @@ async function submitChat(value: string) {
fireEvent.click(screen.getByRole('button', { name: '发送' }));
}
// 同一张资源卡上有两个按钮:打开详情与 V3 新增的 @ 引用按钮,二者的可访问名都包含
// 资源文件名,因此按文件名正则查询会同时命中两个元素。资源详情按钮的可访问名模板固定为
// `打开资源详情<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。
// 同一张资源卡上有两个按钮:选中资源与 V3 新增的 @ 引用按钮,二者的可访问名都包含
// 资源文件名,因此按文件名正则查询会同时命中两个元素。选中按钮的可访问名模板固定为
// `选中资源<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。
function escapeRegExpLiteral(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function resourceDetailButtonName(label: string) {
return new RegExp(`^打开资源详情\\S+ ${escapeRegExpLiteral(label)}$`);
function resourceSelectButtonName(label: string) {
return new RegExp(`^选中资源\\S+ ${escapeRegExpLiteral(label)}$`);
}
function getResourceDetailButton(label: string) {
return screen.getByRole('button', { name: resourceDetailButtonName(label) });
function getResourceSelectButton(label: string) {
return screen.getByRole('button', { name: resourceSelectButtonName(label) });
}
function findResourceDetailButton(
function findResourceSelectButton(
label: string,
options?: { timeout?: number; interval?: number },
) {
return screen.findByRole(
'button',
{ name: resourceDetailButtonName(label) },
{ name: resourceSelectButtonName(label) },
options,
);
}
function queryResourceDetailButton(label: string) {
function queryResourceSelectButton(label: string) {
return screen.queryByRole('button', {
name: resourceDetailButtonName(label),
name: resourceSelectButtonName(label),
});
}
@@ -1293,10 +1293,10 @@ export {
describe,
emptyProjectPolicy,
expect,
findResourceDetailButton,
findResourceSelectButton,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
getResourceDetailButton,
getResourceSelectButton,
it,
mockRoleAgentReply,
nativeClipboardMock,
@@ -1304,7 +1304,7 @@ export {
pickProjectFromLauncher,
ProjectDevelopmentView,
projectSupervisorResponseStream,
queryResourceDetailButton,
queryResourceSelectButton,
React,
readFileSync,
render,
@@ -9,13 +9,13 @@ import {
createGameCreationAppManifest,
createProjectSupervisorRuntimeHarness,
expect,
findResourceDetailButton,
findResourceSelectButton,
fireEvent,
getResourceDetailButton,
getResourceSelectButton,
it,
nativeClipboardMock,
pickProjectFromLauncher,
queryResourceDetailButton,
queryResourceSelectButton,
React,
render,
renderAppAt,
@@ -316,12 +316,12 @@ export function registerClientHomeTests() {
pickProjectFromLauncher(projectPath);
const runButton = await screen.findByRole('tab', { name: '运行' });
expect(runButton.getAttribute('data-unavailable')).toBe('true');
expect(queryResourceDetailButton('live-hero.png')).toBeNull();
expect(queryResourceSelectButton('live-hero.png')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' }));
await openResourceBookCategory('美术资源');
expect(await findResourceDetailButton('live-hero.png')).not.toBeNull();
expect(await findResourceSelectButton('live-hero.png')).not.toBeNull();
await openResourceBookCategory('项目版本');
expect(
await screen.findByRole('button', { name: /版本 1/ }),
@@ -462,7 +462,7 @@ export function registerClientHomeTests() {
pickProjectFromLauncher(projectPath);
const runButton = await screen.findByRole('tab', { name: '运行' });
expect(runButton.getAttribute('data-unavailable')).toBe('true');
expect(queryResourceDetailButton('runtime-live-hero.png')).toBeNull();
expect(queryResourceSelectButton('runtime-live-hero.png')).toBeNull();
await waitFor(() => {
expect(runtimeHarness.listen).toHaveBeenCalledWith(
'game-creator-manifest-invalidated',
@@ -488,7 +488,7 @@ export function registerClientHomeTests() {
await openResourceBookCategory('美术资源');
expect(
await findResourceDetailButton('runtime-live-hero.png', {
await findResourceSelectButton('runtime-live-hero.png', {
timeout: 5_000,
}),
).not.toBeNull();
@@ -663,14 +663,14 @@ export function registerClientHomeTests() {
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
pickProjectFromLauncher(secondProjectPath);
await openResourceBookCategory('美术资源');
expect(await findResourceDetailButton('second.png')).not.toBeNull();
expect(await findResourceSelectButton('second.png')).not.toBeNull();
await act(async () => {
resolveStaleRefresh(staleFirstManifest);
await staleRefresh;
});
expect(queryResourceDetailButton('stale-first.png')).toBeNull();
expect(getResourceDetailButton('second.png')).not.toBeNull();
expect(queryResourceSelectButton('stale-first.png')).toBeNull();
expect(getResourceSelectButton('second.png')).not.toBeNull();
});
it('starts from the client home and opens a project in the same window', async () => {
File diff suppressed because it is too large Load Diff
@@ -13,11 +13,13 @@ import ProjectDevelopmentView from '../src/view/project-development';
import {
act,
createGameCreationAppManifest,
findResourceDetailButton,
findResourceSelectButton,
fireEvent,
render,
screen,
setComposerText,
waitFor,
within,
} from './appSurface/harness';
const projectPath = '/tmp/live-canvas-integration';
@@ -473,39 +475,36 @@ describe('project resource live canvas integration', () => {
};
}
it('derives a document non-destructively and retries with the original operation identity', async () => {
it('derives a new art asset non-destructively from the quick edit panel and reuses the original operation identity', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
fireEvent.click(await findResourceDetailButton('rules.md'));
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(await screen.findByText('编辑现有资源')).not.toBeNull();
fireEvent.change(screen.getByLabelText('编辑提示词'), {
target: { value: '把角色头发设定改为红色' },
fireEvent.click(screen.getByRole('button', { name: '打开美术资源' }));
fireEvent.click(await findResourceSelectButton('source-art.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(screen.getByRole('button', { name: '生成派生资源' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
const panel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
await setComposerText(
within(panel).getByLabelText('快速编辑提示词'),
'把角色头发设定改为红色',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
expect(await screen.findByRole('alert')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '使用原请求重试' }));
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(2));
expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId);
expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey);
expect(deriveCalls[0]?.editKind).toBe('image-reference');
expect(deriveCalls[0]?.generationMode).toBe('derive');
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
const operationId = String(deriveCalls[1]?.operationId);
expect(
await screen.findByRole('dialog', {
name: new RegExp(operationId, 'u'),
}),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
await screen.findByRole('button', {
name: '打开资源详情:设计文档 rules.md',
}),
).not.toBeNull();
});
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
@@ -529,11 +528,14 @@ describe('project resource live canvas integration', () => {
expect(resumeCalls[0]).not.toHaveProperty('prompt');
expect(resumeCalls[0]).not.toHaveProperty('endpoint');
expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey');
// 恢复完成后新素材被选中;不再有"资源详情"面板。
expect(
await screen.findByRole('dialog', {
name: /recovered-rules\.md/,
}),
).not.toBeNull();
(
await screen.findByRole('button', {
name: /选中资源:\S+ \S*recovered-rules\.md$/,
})
).getAttribute('aria-pressed'),
).toBe('true');
});
it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => {
@@ -683,8 +685,6 @@ describe('project resource live canvas integration', () => {
expect(resumeCalls[0]?.operationId).toBe(resumableOperationId);
expect(resumeCalls[0]?.operationId).not.toBe(reconciliationOperationId);
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
fireEvent.click(
await screen.findByRole('button', {
name: '管理未完成编辑 (2)',
@@ -10,7 +10,7 @@ import {
Sparkles,
WandSparkles,
} from 'lucide-react';
import type { CSSProperties } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
@@ -19,7 +19,28 @@ import {
isQuickEditSupportedLayer,
} from './ImageCanvasGenerationModel';
/** 选中工具条上的动作标识,供宿主声明自己实际可用的动作集合。 */
export type ImageCanvasSelectedToolbarAction =
| 'quick-edit'
| 'redraw'
| 'crop-expand'
| 'remove-background'
| 'perfect-pixel'
| 'split-icon-spritesheet'
| 'extract-ui-design'
| 'character-animation'
| 'download';
type ImageCanvasSelectedLayerToolbarViewProps = {
/**
* 宿主显式声明的可用动作集合。
*
* 传 `null`(默认)时完全沿用美术画布内置判定;传入集合时以集合为准,
* 让没有对应后端链路的画布宿主不必把「点了没反应」的按钮渲染出来。
*/
supportedActions?: ReadonlySet<ImageCanvasSelectedToolbarAction> | null;
/** 宿主在工具条上追加的动作节点,渲染在下载按钮之前。 */
extraActions?: ReactNode;
selectedLayer: CanvasLayer | null;
selectedToolbarStyle: CSSProperties | null;
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
@@ -38,6 +59,8 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
};
export function ImageCanvasSelectedLayerToolbarView({
supportedActions = null,
extraActions,
selectedLayer,
selectedToolbarStyle,
onOpenQuickEditPanel,
@@ -57,7 +80,20 @@ export function ImageCanvasSelectedLayerToolbarView({
if (!selectedLayer || !selectedToolbarStyle) {
return null;
}
const canRedraw = canOpenRedrawPanel(selectedLayer);
const isActionSupported = (
action: ImageCanvasSelectedToolbarAction,
builtInAvailability: boolean,
) => (supportedActions ? supportedActions.has(action) : builtInAvailability);
const canRedraw = isActionSupported(
'redraw',
canOpenRedrawPanel(selectedLayer),
);
const extraActionDivider = extraActions ? (
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
) : null;
if (selectedLayer.mediaType === 'audio') {
return (
@@ -79,12 +115,16 @@ export function ImageCanvasSelectedLayerToolbarView({
<span></span>
</CanvasChromeButton>
) : null}
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
{extraActionDivider}
{extraActions}
{isActionSupported('download', true) ? (
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
) : null}
</div>
);
}
@@ -93,6 +133,14 @@ export function ImageCanvasSelectedLayerToolbarView({
selectedLayer.mediaType !== 'video' &&
selectedLayer.mediaType !== 'image-sequence' &&
selectedLayer.assetKind !== 'character-animation';
const showQuickEdit = isActionSupported(
'quick-edit',
isQuickEditSupportedLayer(selectedLayer),
);
const showCharacterAnimation = isActionSupported(
'character-animation',
selectedLayer.assetKind === 'character',
);
return (
<div
@@ -102,7 +150,7 @@ export function ImageCanvasSelectedLayerToolbarView({
aria-label="图片工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{isQuickEditSupportedLayer(selectedLayer) ? (
{showQuickEdit ? (
<>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -119,74 +167,77 @@ export function ImageCanvasSelectedLayerToolbarView({
/>
</>
) : null}
{canRasterEdit ? (
<>
<EditorIconButton
label="裁扩按钮"
title="裁扩按钮"
icon={Crop}
onClick={() => onOpenCropExpandPanel(selectedLayer)}
/>
<EditorIconButton
label="去除背景按钮"
title="去除背景按钮"
icon={ImageOff}
onClick={() => onRemoveBackground(selectedLayer)}
/>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
label={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '完美像素结果待确认'
: '完美像素'
}
title={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '结果待确认,双击原占位继续核对或重试'
: '自动识别并规整像素网格'
}
icon={
isPersistingAssetKind || isPerfectPixelProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Grid2X2 className="h-4 w-4" />
)
}
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
// 与相邻的拆分图集按钮保持同一套门禁。
disabled={
isPersistingAssetKind ||
isPerfectPixelProcessing ||
isPerfectPixelPendingConfirmation
}
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
<span>
{isPersistingAssetKind
? '保存中'
: isPerfectPixelProcessing
? '处理中'
: isPerfectPixelPendingConfirmation
? '待确认'
: '完美像素'}
</span>
</CanvasChromeButton>
</>
{canRasterEdit && isActionSupported('crop-expand', true) ? (
<EditorIconButton
label="裁扩按钮"
title="裁扩按钮"
icon={Crop}
onClick={() => onOpenCropExpandPanel(selectedLayer)}
/>
) : null}
{selectedLayer.assetKind === 'icon-spritesheet' ? (
{canRasterEdit && isActionSupported('remove-background', true) ? (
<EditorIconButton
label="去除背景按钮"
title="去除背景按钮"
icon={ImageOff}
onClick={() => onRemoveBackground(selectedLayer)}
/>
) : null}
{canRasterEdit && isActionSupported('perfect-pixel', true) ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
label={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '完美像素结果待确认'
: '完美像素'
}
title={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '结果待确认,双击原占位继续核对或重试'
: '自动识别并规整像素网格'
}
icon={
isPersistingAssetKind || isPerfectPixelProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Grid2X2 className="h-4 w-4" />
)
}
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
// 与相邻的拆分图集按钮保持同一套门禁。
disabled={
isPersistingAssetKind ||
isPerfectPixelProcessing ||
isPerfectPixelPendingConfirmation
}
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
<span>
{isPersistingAssetKind
? '保存中'
: isPerfectPixelProcessing
? '处理中'
: isPerfectPixelPendingConfirmation
? '待确认'
: '完美像素'}
</span>
</CanvasChromeButton>
) : null}
{selectedLayer.assetKind === 'icon-spritesheet' &&
isActionSupported('split-icon-spritesheet', true) ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={
@@ -223,7 +274,8 @@ export function ImageCanvasSelectedLayerToolbarView({
</span>
</CanvasChromeButton>
) : null}
{selectedLayer.assetKind === 'ui-design' ? (
{selectedLayer.assetKind === 'ui-design' &&
isActionSupported('extract-ui-design', true) ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="提取素材"
@@ -234,7 +286,7 @@ export function ImageCanvasSelectedLayerToolbarView({
<span></span>
</CanvasChromeButton>
) : null}
{selectedLayer.assetKind === 'character' ? (
{showCharacterAnimation ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="生成动画"
@@ -262,12 +314,16 @@ export function ImageCanvasSelectedLayerToolbarView({
</CanvasChromeButton>
</>
) : null}
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
{extraActionDivider}
{extraActions}
{isActionSupported('download', true) ? (
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
) : null}
</div>
);
}
+1 -1
View File
@@ -493,7 +493,7 @@ export async function navigateWechatMiniProgramPage(
success() {
resolve();
},
fail(_error) {
fail(_error: unknown) {
console.error('[host-bridge] wechat mini program navigation failed');
reject(new Error(errorMessage));
},