把资源卡的 @ 引用入口挪进选中资源工具条
- 删除资源卡右上角的 @ 引用圆钮,连同它的 44×44 热区伪元素与全部样式一起退役 - 选中资源的画布工具条新增「@引用」动作,排在「UI 编辑器」之后,出站负载与来源逐字未变(source: 'resource-card') - 测试:原「卡片圆钮视觉尺寸」用例改写为「入口只留在工具条里」的源级 + 样式级断言,并补反向断言卡片组件内不再出现插入调用 - 测试:新增键盘通路用例,聚焦后回车必须真正派发 agc-resource-reference-insert,且两条通路负载逐字相同、每次激活只派发一次 - harness 注释同步:资源卡本体只剩「选中资源」与媒体播放钮
This commit is contained in:
@@ -7025,59 +7025,6 @@ iframe.preview-frame {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.game-resource-card-reference {
|
||||
position: absolute;
|
||||
right: 49px;
|
||||
/* 与右侧 34px 播放钮圆心对齐:9 + 34 / 2 = 14 + 24 / 2。 */
|
||||
bottom: 14px;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
/* 视觉按 0.8× 缩小:30 → 24,内部图标 16 → 13 同步缩。 */
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(255 255 255 / 70%);
|
||||
border-radius: 50%;
|
||||
background: rgb(75 48 38 / 82%);
|
||||
color: #fff;
|
||||
box-shadow: 0 5px 14px rgb(52 30 22 / 24%);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* 视觉缩小但热区不缩:伪元素撑出 44×44(本仓 window chrome / 素材选择列表同口径的最小
|
||||
* 可点尺寸),只扩命中范围,不参与布局也不改变绘制;整体向左让 4px,让热区右边缘正好与
|
||||
* 右侧 34px 播放钮的盒子相邻而不覆盖它。
|
||||
*/
|
||||
.game-resource-card-reference::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
transform: translate(calc(-50% - 4px), -50%);
|
||||
}
|
||||
|
||||
.game-resource-card-reference svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.game-resource-card:not(:has(.game-resource-card-media-control))
|
||||
.game-resource-card-reference {
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.game-resource-card-reference:hover,
|
||||
.game-resource-card-reference:focus-visible {
|
||||
background: #c96f44;
|
||||
outline: 2px solid rgb(255 255 255 / 92%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.game-resource-card-placeholder,
|
||||
.game-resource-card-version-visual,
|
||||
.game-resource-card-audio-visual {
|
||||
|
||||
@@ -894,30 +894,6 @@ const ResourceCard = memo(function ResourceCard({
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{resource.manifestAssetId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-card-reference"
|
||||
aria-label={`引用资源 ${resource.label}`}
|
||||
title="@引用"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
dispatchResourceReferenceInsert({
|
||||
type: 'resource',
|
||||
resourceId: resource.manifestAssetId!,
|
||||
kind: resource.subtype || resource.category,
|
||||
mediaType: resource.mediaType,
|
||||
label: resource.label,
|
||||
category: projectResourceAssetCategory(resource),
|
||||
tags: resource.assetTags ?? [],
|
||||
source: 'resource-card',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<AtSign size={16} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -5742,6 +5718,34 @@ export default function ProjectDevelopmentView({
|
||||
<span>UI 编辑器</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={`引用资源 ${selectedResource.label}`}
|
||||
title="@引用"
|
||||
icon={<AtSign className="h-4 w-4" />}
|
||||
onClick={() =>
|
||||
dispatchResourceReferenceInsert({
|
||||
type: 'resource',
|
||||
resourceId:
|
||||
selectedResource.manifestAssetId!,
|
||||
kind:
|
||||
selectedResource.subtype ||
|
||||
selectedResource.category,
|
||||
mediaType: selectedResource.mediaType,
|
||||
label: selectedResource.label,
|
||||
category:
|
||||
projectResourceAssetCategory(
|
||||
selectedResource,
|
||||
),
|
||||
tags: selectedResource.assetTags ?? [],
|
||||
source: 'resource-card',
|
||||
})
|
||||
}
|
||||
>
|
||||
<span>@引用</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
|
||||
@@ -332,8 +332,8 @@ async function submitChat(value: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
}
|
||||
|
||||
// 同一张资源卡上有两个按钮:选中资源与 V3 新增的 @ 引用按钮,二者的可访问名都包含
|
||||
// 资源文件名,因此按文件名正则查询会同时命中两个元素。选中按钮的可访问名模板固定为
|
||||
// 同一张资源卡上的按钮变少了:卡片本体只有「选中资源」与媒体播放钮两个(原先右上角
|
||||
// 那个 @ 引用圆钮已挪进选中工具条)。选中按钮的可访问名模板固定为
|
||||
// `选中资源:<分类标签> <资源文件名>`;分类标签可能自带空格(例如 `UI 交互`),
|
||||
// 这里按模板做精确匹配。
|
||||
function escapeRegExpLiteral(value: string) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import type {
|
||||
ProjectResourceCanvasLayout,
|
||||
ProjectResourceCanvasPosition,
|
||||
} from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
|
||||
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
|
||||
import {
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
@@ -2899,53 +2902,15 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(styles).not.toMatch(/\.game-resource-card-open\s*\{/);
|
||||
});
|
||||
|
||||
it('keeps the resource card @ reference button visually small without shrinking its hit area', () => {
|
||||
it('资源卡的 @ 引用入口只留在选中工具条里', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const reference = styleRuleBody(styles, '\\.game-resource-card-reference');
|
||||
const referenceSize = styleNumber(reference, 'width');
|
||||
// 视觉按 0.8× 缩:原 30×30 圆钮 → 24×24,内部图标 16 → 13。
|
||||
expect(referenceSize).toBe(24);
|
||||
expect(styleNumber(reference, 'height')).toBe(24);
|
||||
expect(referenceSize).toBeLessThan(30);
|
||||
const icon = styleRuleBody(styles, '\\.game-resource-card-reference svg');
|
||||
expect(styleNumber(icon, 'width')).toBe(13);
|
||||
expect(styleNumber(icon, 'height')).toBe(13);
|
||||
expect(styleNumber(icon, 'width')).toBeLessThan(16);
|
||||
// 卡片右上角那个圆钮连同它撑出来的 44×44 热区一起退役:卡片本体只剩
|
||||
// 「选中资源」与媒体播放钮,卡片 chrome 用例(上一条)继续钉这条口径。
|
||||
expect(styles).not.toContain('.game-resource-card-reference');
|
||||
|
||||
// 热区不跟着缩:伪元素撑到 44×44(本仓 window chrome / 素材选择列表同口径的最小可点尺寸)。
|
||||
const hitArea = styleRuleBody(
|
||||
styles,
|
||||
'\\.game-resource-card-reference::before',
|
||||
);
|
||||
const hitSize = styleNumber(hitArea, 'width');
|
||||
expect(hitSize).toBeGreaterThanOrEqual(44);
|
||||
expect(styleNumber(hitArea, 'height')).toBeGreaterThanOrEqual(44);
|
||||
expect(hitSize).toBeGreaterThan(referenceSize);
|
||||
expect(hitArea).toMatch(/position:\s*absolute/u);
|
||||
|
||||
// 热区向左让开后与右侧 34px 播放钮的盒子相邻而不覆盖:不许为了撑热区去挤压播放钮。
|
||||
const shiftLeft = Number(
|
||||
/translate\(calc\(-50% - (\d+)px\), -50%\)/u.exec(hitArea)?.[1],
|
||||
);
|
||||
expect(shiftLeft).toBeGreaterThan(0);
|
||||
const playButton =
|
||||
/\.game-resource-card-media-control\s*\{[^}]*right:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/su.exec(
|
||||
styles,
|
||||
);
|
||||
expect(playButton).not.toBeNull();
|
||||
const playBoxLeftFromRight =
|
||||
Number(playButton![1]) + Number(playButton![2]);
|
||||
const hitAreaLeftFromRight =
|
||||
styleNumber(reference, 'right') +
|
||||
referenceSize / 2 -
|
||||
hitSize / 2 +
|
||||
shiftLeft;
|
||||
expect(hitAreaLeftFromRight).toBeGreaterThanOrEqual(playBoxLeftFromRight);
|
||||
|
||||
// 语义未动:入口仍在资源卡上,仍是「引用资源 <label>」触发插入素材引用。
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
@@ -2953,9 +2918,169 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/className="game-resource-card-reference"[\s\S]*?aria-label=\{`引用资源 \$\{resource\.label\}`\}[\s\S]*?dispatchResourceReferenceInsert\(\{/u,
|
||||
expect(source).not.toContain('game-resource-card-reference');
|
||||
// 反向守卫:卡片组件内部不能再出现任何插入素材引用的调用。
|
||||
const cardStart = source.indexOf('function ResourceCard(');
|
||||
const cardEnd = source.indexOf('function ResourceBookThumbnail(');
|
||||
expect(cardStart).toBeGreaterThan(0);
|
||||
expect(cardEnd).toBeGreaterThan(cardStart);
|
||||
expect(source.slice(cardStart, cardEnd)).not.toContain(
|
||||
'dispatchResourceReferenceInsert',
|
||||
);
|
||||
|
||||
// 入口挪进选中工具条的 `extraActions`:用插槽边界切片,确保它落在宿主编排层
|
||||
// 注入的动作里,而不是又被挂回卡片或塞到工具条外面。
|
||||
const extraStart = source.indexOf('extraActions={');
|
||||
const extraEnd = source.indexOf(
|
||||
'onOpenQuickEditPanel={openResourceQuickEditPanel}',
|
||||
);
|
||||
expect(extraStart).toBeGreaterThan(0);
|
||||
expect(extraEnd).toBeGreaterThan(extraStart);
|
||||
const extraActions = source.slice(extraStart, extraEnd);
|
||||
expect(extraActions).toContain(
|
||||
'label={`引用资源 ${selectedResource.label}`}',
|
||||
);
|
||||
expect(extraActions).toContain('title="@引用"');
|
||||
expect(extraActions).toContain('<span>@引用</span>');
|
||||
expect(extraActions).toMatch(
|
||||
/<CanvasChromeButton[\s\S]*?label=\{`引用资源 \$\{selectedResource\.label\}`\}[\s\S]*?dispatchResourceReferenceInsert\(\{/u,
|
||||
);
|
||||
// 出站契约逐字未变:仍是 `resource-card` 来源的 resource 引用。
|
||||
const insertStart = extraActions.indexOf(
|
||||
'dispatchResourceReferenceInsert({',
|
||||
);
|
||||
expect(insertStart).toBeGreaterThan(0);
|
||||
expect(extraActions.slice(insertStart)).toMatch(
|
||||
/\{\s*type: 'resource',\s*resourceId:\s*selectedResource\.manifestAssetId!,\s*kind:\s*selectedResource\.subtype \|\|\s*selectedResource\.category,\s*mediaType: selectedResource\.mediaType,\s*label: selectedResource\.label,\s*category:\s*projectResourceAssetCategory\(\s*selectedResource,\s*\),\s*tags: selectedResource\.assetTags \?\? \[\],\s*source: 'resource-card',/u,
|
||||
);
|
||||
});
|
||||
|
||||
it('工具条里的 @引用 用键盘也能插进聊天输入框', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-resource-reference',
|
||||
'引用入口测试',
|
||||
);
|
||||
manifest.assets.push({
|
||||
id: 'scene-hero',
|
||||
kind: 'image',
|
||||
category: 'scene',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/hero.png',
|
||||
tags: ['主舞台'],
|
||||
source: { kind: 'generated' },
|
||||
});
|
||||
let layoutRevision = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: manifest.projectId,
|
||||
mode: args?.mode,
|
||||
revision: layoutRevision,
|
||||
positions: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
layoutRevision += 1;
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: manifest.projectId,
|
||||
mode: args?.mode,
|
||||
revision: layoutRevision,
|
||||
positions: args?.positions,
|
||||
updatedAt: layoutRevision,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_media_preview') {
|
||||
return {
|
||||
path: 'assets/hero.png',
|
||||
mediaType: 'image/png',
|
||||
byteLen: 48,
|
||||
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: '引用入口测试',
|
||||
projectPath: '/tmp/workbench-resource-reference',
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await openResourceBookCategory('场景与环境');
|
||||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||||
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
const referenceButton = within(toolbar).getByRole('button', {
|
||||
name: '引用资源 hero.png',
|
||||
});
|
||||
// 原生按钮 = 天生进 Tab 序;标题沿用「@引用」,与卡片入口时期的说法一致。
|
||||
expect(referenceButton.tagName).toBe('BUTTON');
|
||||
expect(referenceButton.getAttribute('type')).toBe('button');
|
||||
expect(referenceButton.getAttribute('title')).toBe('@引用');
|
||||
|
||||
const inserted: unknown[] = [];
|
||||
const onInsert = (event: Event) => {
|
||||
inserted.push(
|
||||
(event as CustomEvent<{ reference: unknown }>).detail.reference,
|
||||
);
|
||||
};
|
||||
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
|
||||
try {
|
||||
// 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip
|
||||
// (`App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。
|
||||
referenceButton.focus();
|
||||
expect(document.activeElement).toBe(referenceButton);
|
||||
await userEvent.setup().keyboard('{Enter}');
|
||||
expect(inserted).toHaveLength(1);
|
||||
|
||||
// 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。
|
||||
fireEvent.click(referenceButton);
|
||||
expect(inserted).toHaveLength(2);
|
||||
} finally {
|
||||
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
|
||||
}
|
||||
expect(inserted).toEqual([
|
||||
{
|
||||
type: 'resource',
|
||||
resourceId: 'scene-hero',
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
label: 'hero.png',
|
||||
category: 'scene',
|
||||
tags: ['主舞台'],
|
||||
source: 'resource-card',
|
||||
},
|
||||
{
|
||||
type: 'resource',
|
||||
resourceId: 'scene-hero',
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
label: 'hero.png',
|
||||
category: 'scene',
|
||||
tags: ['主舞台'],
|
||||
source: 'resource-card',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
@@ -3146,10 +3271,10 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
),
|
||||
).toBe(false);
|
||||
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
|
||||
// 并且只渲染宿主编排层真实接通的动作:「编辑标签」(面板只编辑 manifest
|
||||
// `assets[].tags`,分类不再有手动入口)「重命名」已接面板,
|
||||
// 「下载按钮」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调,
|
||||
// 不能再渲染成点了没反应的按钮。
|
||||
// 并且只渲染宿主编排层真实接通的动作:「@引用」(从卡片挪进工具条的引用入口,
|
||||
// 资源卡上的圆钮已删除)「编辑标签」(面板只编辑 manifest `assets[].tags`,
|
||||
// 分类不再有手动入口)「重命名」已接面板,「下载按钮」复用资源面板同一条落盘
|
||||
// 链路,「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。
|
||||
const audioToolbar = screen.getByRole('toolbar', {
|
||||
name: '素材工具栏',
|
||||
});
|
||||
@@ -3160,7 +3285,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
within(audioToolbar)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['编辑标签', '重命名', '下载按钮']);
|
||||
).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '下载按钮']);
|
||||
|
||||
// 工具条的「下载按钮」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制,
|
||||
// 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了
|
||||
|
||||
Reference in New Issue
Block a user