From 7edc452c6ac615f074a627069b480d3a5dfe5eb3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:08:38 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84AGC=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E8=8F=9C=E5=8D=95=E4=B8=8E=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88#409=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主菜单保留前五项,其余操作通过向上展开的更多菜单访问 类型与信息入口下沉资源卡片,并复用共享画布角标 补充菜单键盘、滚轮归属、换选及资源操作链路回归 同步方案A规范与待人工验收记录 --- .../resourceCanvasFocusModel.ts | 2 +- apps/ai-game-creator-shell/src/styles.css | 27 -- .../src/view/project-development/index.tsx | 90 +++-- .../tests/appSurface/home.suite.ts | 4 +- .../appSurface/project-development.suite.ts | 26 +- .../projectResourceLiveIntegration.test.tsx | 9 +- .../resourceCanvasFloatingDismiss.test.tsx | 2 + .../tests/resourceVersionReplacement.test.tsx | 97 ++++- .../【实施计划】AGC资源菜单收纳-2026-09-17.md | 10 + .../【里程碑】AGC资源菜单收纳-2026-09-17.md | 31 ++ .../shared-memory/team-conventions.md | 1 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + .../components/CanvasCardCornerActions.tsx | 67 ++++ .../src/components/OverflowActions.test.tsx | 206 ++++++++++ .../shared/src/components/OverflowActions.tsx | 237 ++++++++++++ packages/shared/src/components/index.ts | 2 + packages/shared/src/components/styles.css | 123 ++++++ ...ageCanvasSelectedLayerToolbarView.test.tsx | 44 +++ .../ImageCanvasSelectedLayerToolbarView.tsx | 359 +++++++++--------- .../ImageCanvasWorldView.test.tsx | 11 +- .../image-editor/ImageCanvasWorldView.tsx | 57 +-- vitest.config.ts | 7 + 22 files changed, 1068 insertions(+), 346 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md create mode 100644 packages/shared/src/components/CanvasCardCornerActions.tsx create mode 100644 packages/shared/src/components/OverflowActions.test.tsx create mode 100644 packages/shared/src/components/OverflowActions.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index 92ba12136..a61125c41 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -49,7 +49,7 @@ export function isResourceCanvasPanTarget( return Boolean( target.closest('.game-resource-card') && !target.closest( - 'button:not(.game-resource-card-select), input, textarea, select, a, audio, video', + 'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video', ), ); } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 613ec51e4..ed82b7507 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7494,33 +7494,6 @@ iframe.preview-frame { visibility: visible; } -.game-resource-card-type-badge { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; - display: inline-flex; - max-width: calc(100% - 16px); - align-items: center; - min-width: 0; - padding: 4px 8px; - overflow: hidden; - border: 1px solid rgb(255 255 255 / 72%); - border-radius: 999px; - background: rgb(75 48 38 / 84%); - color: #fff; - font-size: 10px; - font-weight: 900; - line-height: 1; - letter-spacing: 0.02em; - pointer-events: none; - text-overflow: ellipsis; - white-space: nowrap; - box-shadow: 0 8px 18px rgb(96 62 47 / 20%); - transform: scale(var(--genarrative-image-canvas-inverse-scale, 1)); - transform-origin: top right; -} - /* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。 左下角是卡片上唯一空闲的角(右上角标是类型、右下是媒体播放钮),用「当前版本」同一支橙色 把这条关系与光环联系起来。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index e660c2c73..cd0e4276a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -15,6 +15,7 @@ import { SelectionOverlay, } from '@genarrative/image-canvas-react'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AtSign, Crosshair, @@ -42,7 +43,6 @@ import { Replace, RotateCcw, Search, - Shapes, SlidersHorizontal, Sparkles, Trash2, @@ -695,6 +695,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize, activeMediaIdentity, onSelect, + onShowInfo, + onChangeType, + infoPressed, onPointerDown, onPointerMove, onPointerUp, @@ -724,6 +727,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize: ResourceCanvasCardSize; activeMediaIdentity: string | null; onSelect: (resourceId: string, options?: { append?: boolean }) => void; + onShowInfo: (resource: ProjectResource) => void; + onChangeType: (assetId: string) => void; + infoPressed: boolean; onPointerDown: ( event: ReactPointerEvent, resource: ProjectResource, @@ -1072,13 +1078,19 @@ const ResourceCard = memo(function ResourceCard({ - - {cardTypeLabel} - + onChangeType(resource.manifestAssetId!) + : undefined + } + onInfoClick={() => onShowInfo(resource)} + /> {lineage ? ( // 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据 // (稳定 id 见卡上的 `data-resource-replaced-by` / `data-resource-replacement-of`)。 @@ -1604,7 +1616,12 @@ export default function ProjectDevelopmentView({ const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); /** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */ - const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false); + const [resourceInfoResourceId, setResourceInfoResourceId] = useState< + string | null + >(null); + const resourceInfoPanelOpen = + resourceInfoResourceId !== null && + selectedResourceIds[0] === resourceInfoResourceId; const [resourceCanvasMarquee, setResourceCanvasMarquee] = useState(null); /** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */ @@ -1909,7 +1926,7 @@ export default function ProjectDevelopmentView({ */ const clearResourceCanvasFocus = useCallback(() => { setSelectedResourceIds([]); - setResourceInfoPanelOpen(false); + setResourceInfoResourceId(null); if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) { return; } @@ -2333,7 +2350,9 @@ export default function ProjectDevelopmentView({ * 是因为选中本身有多个清空入口(清焦点、切视图、切项目)。 */ useEffect(() => { - setResourceInfoPanelOpen(false); + setResourceInfoResourceId((current) => + current === selectedResourceId ? current : null, + ); }, [selectedResourceId]); const projectVersions = useMemo( () => manifest.versions ?? [], @@ -6262,6 +6281,16 @@ export default function ProjectDevelopmentView({ const showRunUnavailableHint = !runAvailable && !uiEditorRoute; + const showResourceCardInfo = useCallback( + (resource: ProjectResource) => { + handleResourceSelect(resource.id); + setResourceInfoResourceId((current) => + current === resource.id ? null : resource.id, + ); + }, + [handleResourceSelect], + ); + const renderResourceBookCard = useCallback( ( resource: ProjectResource, @@ -6319,6 +6348,9 @@ export default function ProjectDevelopmentView({ } activeMediaIdentity={activeCardMediaIdentity} onSelect={handleResourceSelect} + onShowInfo={showResourceCardInfo} + onChangeType={setResourceTypeAssetId} + infoPressed={resourceInfoResourceId === resource.id} onPointerDown={(event) => handleResourceCardPointerDown( event, @@ -6350,6 +6382,8 @@ export default function ProjectDevelopmentView({ handleResourceCardPointerMove, handleResourceCardPointerUp, handleResourceSelect, + showResourceCardInfo, + resourceInfoResourceId, resourceCardDragPreview, resourceCardPreviews, resourceReplacementLineageBadgeMap, @@ -7976,6 +8010,7 @@ export default function ProjectDevelopmentView({ ) || selectedResourceOpensUiEditor) ? ( 引用 ) : null} - {selectedResource ? ( - } - onClick={() => - setResourceInfoPanelOpen((open) => !open) - } - > - 信息 - - ) : null} {selectedResource?.manifestAssetId ? ( 编辑标签 ) : null} - {selectedResource?.manifestAssetId ? ( - } - onClick={() => - setResourceTypeAssetId( - selectedResource.manifestAssetId, - ) - } - > - 素材类型 - - ) : null} {selectedResource?.manifestAssetId ? ( setResourceInfoPanelOpen(false)} + onClose={() => setResourceInfoResourceId(null)} /> ) : null} diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index a1ab75aad..6de24b699 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -338,7 +338,7 @@ export function registerClientHomeTests() { expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { @@ -509,7 +509,7 @@ export function registerClientHomeTests() { ).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 636ec3d74..f4b95519e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3817,21 +3817,10 @@ export function registerProjectWorkbenchFoundationTests() { ]; await openResourceBookCategory('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - const toolbarLabels = within(toolbar) - .getAllByRole('button') - .map((button) => button.getAttribute('aria-label') ?? ''); - const infoButton = within(toolbar).getByRole('button', { name: '信息' }); - // 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。 - expect(toolbarLabels.indexOf('信息')).toBeGreaterThan( - toolbarLabels.indexOf('引用资源 hero.png'), - ); - expect(toolbarLabels.indexOf('信息')).toBeLessThan( - toolbarLabels.indexOf('编辑标签'), - ); + const infoButton = await screen.findByRole('button', { name: '查看hero.png资源信息' }); expect(infoButton.getAttribute('aria-pressed')).toBe('false'); + // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。 fireEvent.click(infoButton); const canvasPanel = await screen.findByRole('dialog', { name: '资源信息', @@ -3863,11 +3852,11 @@ export function registerProjectWorkbenchFoundationTests() { // Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。 fireEvent.click(await findResourceSelectButton('hero.png')); - const reopenedToolbar = await screen.findByRole('toolbar', { + await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click( - within(reopenedToolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看hero.png资源信息' }), ); expect( await screen.findByRole('dialog', { name: '资源信息' }), @@ -4075,10 +4064,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toBe(false); // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), - // 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口, - // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest - // `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」 - // 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程) + // 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。 // 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调, // 不能再渲染成点了没反应的按钮。 const audioToolbar = screen.getByRole('toolbar', { @@ -4093,9 +4079,7 @@ export function registerProjectWorkbenchFoundationTests() { .map((button) => button.getAttribute('aria-label')), ).toEqual([ '引用资源 bgm.mp3', - '信息', '编辑标签', - '素材类型', '重命名', '导出', '删除素材', diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 48938bee7..4234d82b8 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -1914,9 +1914,8 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); expect(await cardBadgeText('hero.png')).toBe('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + // 未选中资源也能直接从卡片类型角标进入,不依赖工具栏存在。 + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); const dialog = await screen.findByRole('dialog', { name: '设置素材类型', }); @@ -1993,7 +1992,7 @@ describe('project resource live canvas integration', () => { const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); // 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。 - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); await screen.findByRole('dialog', { name: '设置素材类型' }); fireEvent.click(document.body); expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull(); @@ -2073,7 +2072,7 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '信息' })); + fireEvent.click(screen.getByRole('button', { name: '查看hero.png资源信息' })); const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index 22018450b..98ee615bc 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -48,6 +48,7 @@ describe('resourceCanvasFocusModel', () => {
+ 信息
@@ -60,6 +61,7 @@ describe('resourceCanvasFocusModel', () => { } for (const id of [ 'play', + 'corner', 'video', 'input', 'editor', diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index 380692d66..cfbc324f9 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -375,6 +375,13 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { return { invoke, onActiveVersionChange, onPlay, onManifestChange }; } +function toolbarAction(toolbar: HTMLElement, name: string) { + const visible = within(toolbar).queryByRole('button', { name }); + if (visible) return visible; + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + return within(screen.getByRole('group', { name: '更多操作' })).getByRole('button', { name }); +} + async function selectCardAndOpenToolbar(label: string) { await waitFor(() => expect( @@ -538,13 +545,61 @@ function installResourceCardIntersectionObserver() { } describe('版本级资源替换', () => { + it('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => { + renderReplacementWorkbench(); + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + const menu = screen.getByRole('group', { name: '更多操作' }); + const viewport = () => document.querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); + const before = viewport(); + expect(before).toBeTruthy(); + const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }); + act(() => { menu.dispatchEvent(wheel); }); + expect(wheel.defaultPrevented).toBe(false); + expect(viewport()).toBe(before); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + + const scene = document.querySelector('.game-resource-book-scene')!; + act(() => { + scene.dispatchEvent(new WheelEvent('wheel', { + bubbles: true, cancelable: true, deltaY: 120, clientX: 90, clientY: 70, + })); + }); + expect(viewport()).not.toBe(before); + }); + + it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => { + renderReplacementWorkbench(); + await selectCardAndOpenToolbar('legacy.png'); + const lateInfo = screen.getByRole('button', { name: '查看late.png资源信息' }); + fireEvent.pointerDown(lateInfo, { button: 0 }); + fireEvent.click(lateInfo); + const panel = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(panel).getByText('late.png')).toBeTruthy(); + expect(lateInfo.getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(screen.getByRole('button', { name: '查看legacy.png资源信息' })); + const switched = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(switched).getByText('legacy.png')).toBeTruthy(); + expect(within(switched).queryByText('late.png')).toBeNull(); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + }); + it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => { const { invoke } = renderReplacementWorkbench(); // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 const lateToolbar = await selectCardAndOpenToolbar('late.png'); + fireEvent.mouseEnter(within(lateToolbar).getByRole('button', { name: '更多' })); expect( - within(lateToolbar).queryByRole('button', { name: '替换素材' }), + screen.queryByRole('button', { name: '替换素材' }), ).toBeNull(); expect( within(lateToolbar).getByRole('button', { name: '快速编辑' }), @@ -560,7 +615,7 @@ describe('版本级资源替换', () => { // 被当前版本绑定的素材:入口出现。 const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); expect( - within(sourceToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(sourceToolbar, '替换素材'), ).not.toBeNull(); }); @@ -569,7 +624,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -675,7 +730,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -709,7 +764,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -735,7 +790,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -787,7 +842,7 @@ describe('版本级资源替换', () => { ).length; const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -837,8 +892,11 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - const toolbarLabels = within(toolbar) - .getAllByRole('button') + const deleteButton = toolbarAction(toolbar, '删除素材'); + const toolbarLabels = [ + ...within(toolbar).getAllByRole('button'), + ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole('button'), + ] .map((button) => button.getAttribute('aria-label') ?? ''); // 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。 expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan( @@ -848,9 +906,6 @@ describe('版本级资源替换', () => { toolbarLabels.indexOf('导出'), ); // 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。 - const deleteButton = within(toolbar).getByRole('button', { - name: '删除素材', - }); const divider = deleteButton.previousElementSibling; expect(divider?.getAttribute('class')).toMatch( /(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/, @@ -914,7 +969,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' })); + fireEvent.click(toolbarAction(toolbar, '删除素材')); const dialog = await screen.findByRole('dialog', { name: '确认删除资源' }); fireEvent.click( within(dialog).getByRole('checkbox', { @@ -976,7 +1031,7 @@ describe('版本级资源替换', () => { ).toBeNull(); // 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。 expect( - within(toolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看草稿.png资源信息' }), ).not.toBeNull(); }); @@ -1056,7 +1111,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1084,7 +1139,7 @@ describe('版本级资源替换', () => { const { invoke, onManifestChange } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1155,7 +1210,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1238,7 +1293,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1274,7 +1329,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1357,7 +1412,7 @@ describe('版本级资源替换', () => { // 第一次:legacy → final。 const legacyToolbar = await selectCardAndOpenToolbar('legacy.png'); fireEvent.click( - within(legacyToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(legacyToolbar, '替换素材'), ); let dialog = await screen.findByRole('dialog', { name: '选择替换素材', @@ -1377,7 +1432,7 @@ describe('版本级资源替换', () => { // 第二次:final → final.webp(同一个工作台会话内)。 const finalToolbar = await selectCardAndOpenToolbar('final.png'); fireEvent.click( - within(finalToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(finalToolbar, '替换素材'), ); dialog = await screen.findByRole('dialog', { name: '选择替换素材' }); fireEvent.click( diff --git a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md new file mode 100644 index 000000000..96333cf43 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,10 @@ +# AGC 资源菜单收纳实施计划 + +对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。 + +1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。 +2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。 +3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。 +4. 并行执行定向 Vitest、AGC 类型检查、编码与文档索引检查,再自审整体调用链。 + +风险:portal 浮层点击外部判定、缩放角标与拖拽冲突、原测试依赖完整工具栏。回滚仅撤销本分支 UI 与文档修改;无数据迁移。首个检查点为组件用例通过,第二个为工作台集成与类型检查。真实客户端未测则明确保留待验收状态。 diff --git a/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md new file mode 100644 index 000000000..1c2ac43c9 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,31 @@ +# AGC 资源菜单收纳 + +- Version: 1 +- Status: implemented,本地自动化通过,待真实客户端验收 +- Date: 2026-09-17 +- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md + +## 范围与评审 + +仅调整前端入口和临时浮层状态,不修改资源命令、权限、持久化、后端或 Web 默认菜单行为。主菜单保留前 5 项,其余悬停/点击向上展开;类型与信息下沉卡片。产品已选定方案 A,边界与既有资源操作合同无冲突,按单里程碑实施。对应 Issue #409,已获得创建 Issue 与本地实施授权;推送、PR 与飞书写入仍需单独确认。 + +## 验收 + +- 动作顺序、禁用状态和回调保持一致,少于等于 5 项不出现更多。 +- 更多支持鼠标移入浮层、点击、键盘、外部关闭与视口约束。 +- 卡片类型、信息入口不触发拖拽;未选中卡直接看信息,换选不残留旧信息。 +- 定向组件与工作台测试、类型检查、编码/文档索引/diff 检查通过;真实客户端视觉和触摸板手感单独验收。 + +## 产品结论与验收待办 + +正式实现采用方案 A;方案 B 不进入工作台,A/B 演示只留在忽略目录供本地参考。 + +完整工作台回归的 2 项失败已定位为新增信息按钮导致“版本 1”模糊匹配重复,改为精确查询资源选中按钮,完整重跑通过。 + +## 验收证据 + +- `appSurface.test.ts`:450 项通过、20 项跳过。 +- 收纳/卡片、Web 工具栏/卡片、资源类型实时链路、替换/重命名、浮层判据、动作可用性:178 项通过;追加真实工作台「更多滚轮不平移画布、Escape 仅收菜单、换选收起」与「跨卡片信息身份」2 项通过。 +- 根目录类型检查与 AGC 类型检查(含 skill-pack / config 检查)通过;编码、文档索引与 diff 检查通过。 +- 浏览器中真实组件预览已检查上方展开、执行回调后关闭、卡片信息入口;预览仅用演示数据,不替代真实 AGC 客户端。 +- 剩余:真实客户端、原生保存对话框、触摸板操作人工验收。未推送,未创建 PR,未更新飞书。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 98662a4e2..6f64a3000 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -16,6 +16,7 @@ ## 开发中 +- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 - 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。 - UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。 - AGC 当前 Agent 与策划 Agent 的消息层级共用 `packages/shared` 的 `AgentMessageContent`:正文使用 `body`,思考、中间输出与工具调用使用 `process`;宿主不按 Agent 类型重新定义过程字号和颜色,错误状态保留语义色。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dec89f82b..5bb7d5633 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -15,6 +15,8 @@ ## 资源卡选中工具栏与导出 +- AGC 选中工具栏按既有动作顺序最多直接显示前 5 项(不计分隔线),剩余动作进入「更多」。悬停、点击及键盘均可展开独立纵向浮层,优先向上展开,窗口顶边空间不足时向下避让;浮层限制在窗口内,超高时自行滚动,不带动画布。动作执行、点击外部、Escape 或换选资源后关闭;禁用状态和原处理链路保持不变。Web 美术画布默认不折叠。 +- 「素材类型」与「信息」不占工具栏名额,改为资源卡右上角的类型标签和信息圆钮,与 Web 美术画布共用卡片控件。未选中卡片可直接打开信息;类型入口仅对 manifest 资产可用。控件不触发卡片拖拽或多选,信息面板仍复用运行页签的字段。 - 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。 - AGC 所有具有本地文件路径的素材都显示带文字的「导出」按钮,位于工具栏末组的「删除素材」之前,两者之间不插入分隔线;「重命名」继续保留在前面的常规动作组。工具栏宽度上限为 `min(92vw, 800px)`,窄屏仍可横向滚动。图片、视频、音频、动画、UI、文档及其它文件共用 `isResourceCanvasExportable`,不按媒体类型限制导出;无文件路径及虚拟项目版本不提供文件导出入口。 - 导出继续复用 `saveProjectResourcesToDisk`:原生保存对话框选择路径,`save_local_project_asset_file` 复制原始文件字节,不转图片、不重编码、不另建 IPC。后端继续校验源文件、敏感路径和目标路径;取消不写文件,失败通过工作台提示。 diff --git a/packages/shared/src/components/CanvasCardCornerActions.tsx b/packages/shared/src/components/CanvasCardCornerActions.tsx new file mode 100644 index 000000000..4199f474c --- /dev/null +++ b/packages/shared/src/components/CanvasCardCornerActions.tsx @@ -0,0 +1,67 @@ +import { Info } from 'lucide-react'; +import type { CSSProperties, Ref } from 'react'; +import { PlatformIconButton } from './PlatformIconButton'; + +/** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */ +export function CanvasCardCornerActions({ + kindLabel, + kindAriaLabel, + kindClassName, + infoLabel, + style, + kindRef, + onKindClick, + onInfoClick, + infoPressed, +}: { + kindLabel?: string | null; + kindAriaLabel?: string; + kindClassName?: string; + infoLabel: string; + style?: CSSProperties; + kindRef?: Ref; + onKindClick?: () => void; + onInfoClick: () => void; + infoPressed?: boolean; +}) { + return ( + event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {kindLabel ? ( + { + if (onKindClick && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onKindClick(); + } + }} + > + {kindLabel} + + ) : null} + + ); +} diff --git a/packages/shared/src/components/OverflowActions.test.tsx b/packages/shared/src/components/OverflowActions.test.tsx new file mode 100644 index 000000000..5f25b8d3a --- /dev/null +++ b/packages/shared/src/components/OverflowActions.test.tsx @@ -0,0 +1,206 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { OverflowActions } from './OverflowActions'; +import { CanvasCardCornerActions } from './CanvasCardCornerActions'; + +afterEach(cleanup); +describe('操作收纳与卡片角标', () => { + it('具名入口可收纳全部动作,并优先向上展开', () => { + render( + + + + , + ); + const trigger = screen.getByRole('button', { name: '素材处理' }); + expect(screen.getAllByRole('button')).toHaveLength(1); + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + left: 200, + right: 300, + top: 350, + bottom: 380, + width: 100, + height: 30, + x: 200, + y: 350, + toJSON: () => ({}), + }); + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(100); + fireEvent.mouseEnter(trigger); + const group = screen.getByRole('group', { name: '素材处理操作' }); + expect(parseFloat(group.style.top)).toBeLessThan(350); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['快速编辑', '改造']); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('前五项可见,跳过分隔符和空 fragment,悬停展示剩余项并执行原回调', () => { + const click = vi.fn(); + render( + + <> + + , + ); + expect(screen.getAllByRole('button')).toHaveLength(6); + expect(screen.queryByText('六')).toBeNull(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['六', '七']); + expect((screen.getByText('七') as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByText('六')); + expect(click).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('悬停后点击仍展开、移入浮层不消失、外部关闭,Escape 恢复焦点', () => { + vi.useFakeTimers(); + render( + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + fireEvent.mouseEnter(more); + fireEvent.click(more); + fireEvent.mouseLeave(more); + fireEvent.mouseEnter(screen.getByRole('group')); + vi.advanceTimersByTime(200); + expect(screen.getByText('二')).toBeTruthy(); + fireEvent.keyDown(screen.getByText('二'), { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + expect(document.activeElement).toBe(more); + fireEvent.click(more); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole('group')).toBeNull(); + vi.useRealTimers(); + }); + it('不溢出不显示更多,默认保持 Web 原样', () => { + const view = render( + + + + , + ); + expect(screen.queryByText('更多')).toBeNull(); + view.rerender( + + {Array.from({ length: 10 }, (_, i) => ( + + ))} + , + ); + expect(screen.getAllByRole('button')).toHaveLength(10); + }); + it('动作减少至不溢出后关闭浮层,恢复动作不会自动重开', () => { + const view = render( + + + + , + ); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + expect(screen.getByRole('group')).toBeTruthy(); + view.rerender( + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + view.rerender( + + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('浮层在窗口右下边界向上展开,方向键跳过禁用项', () => { + render( + + + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + vi.spyOn(more, 'getBoundingClientRect').mockReturnValue({ + left: window.innerWidth - 35, + right: window.innerWidth, + top: window.innerHeight - 40, + bottom: window.innerHeight - 10, + width: 35, + height: 30, + x: 0, + y: 0, + toJSON: () => ({}), + }); + // jsdom 没有真实布局,提供浮层测量以验证向上定位。 + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(200); + fireEvent.click(more); + const panel = screen.getByRole('group'); + expect(parseFloat(panel.style.top)).toBeLessThan(window.innerHeight - 40); + screen.getByText('二').focus(); + fireEvent.keyDown(screen.getByText('二'), { key: 'ArrowDown' }); + expect(document.activeElement).toBe(screen.getByText('四')); + fireEvent.keyDown(screen.getByText('四'), { key: 'Home' }); + expect(document.activeElement).toBe(screen.getByText('二')); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('卡片角标阻断指针和键盘冒泡,类型及信息分别执行', () => { + const parent = vi.fn(), + kind = vi.fn(), + info = vi.fn(); + render( +
+ +
, + ); + const label = screen.getByRole('button', { name: '素材类型' }); + fireEvent.pointerDown(label); + fireEvent.click(label); + fireEvent.keyDown(label, { key: 'Enter' }); + fireEvent.click(screen.getByRole('button', { name: '资源信息' })); + expect(kind).toHaveBeenCalledTimes(2); + expect(info).toHaveBeenCalledOnce(); + expect(parent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/OverflowActions.tsx b/packages/shared/src/components/OverflowActions.tsx new file mode 100644 index 000000000..d79421308 --- /dev/null +++ b/packages/shared/src/components/OverflowActions.tsx @@ -0,0 +1,237 @@ +import { + Children, + cloneElement, + Fragment, + isValidElement, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; + +type ActionProps = { children?: ReactNode; 'aria-hidden'?: boolean | 'true' }; + +function flatten(nodes: ReactNode, prefix = ''): ReactNode[] { + return Children.toArray(nodes).flatMap((node, index) => + isValidElement(node) && node.type === Fragment + ? flatten(node.props.children, `${prefix}${index}.`) + : [ + isValidElement(node) + ? cloneElement(node, { key: `${prefix}${index}` }) + : node, + ], + ); +} + +function isDivider(node: ReactNode) { + return ( + isValidElement(node) && + (node.props['aria-hidden'] === true || node.props['aria-hidden'] === 'true') + ); +} + +/** 只负责展示收纳;动作权限、禁用与执行仍由调用方提供。 */ +export function OverflowActions({ + children, + maxVisible = Infinity, + label = '更多', +}: { + children: ReactNode; + maxVisible?: number; + label?: string; +}) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 320 }); + const trigger = useRef(null); + const panel = useRef(null); + const timer = useRef | null>(null); + const id = useId(); + const limit = Number.isFinite(maxVisible) + ? Math.max(0, Math.floor(maxVisible)) + : Infinity; + const nodes = flatten(children); + let count = 0; + const split = nodes.findIndex((node) => !isDivider(node) && ++count > limit); + const primary = split < 0 ? nodes : nodes.slice(0, split); + while (primary.length && isDivider(primary[primary.length - 1])) + primary.pop(); + const overflow = + split < 0 ? [] : nodes.slice(split).filter((node) => !isDivider(node)); + useEffect(() => { + if (overflow.length === 0) setOpen(false); + }, [overflow.length]); + const cancelClose = () => { + if (timer.current !== null) clearTimeout(timer.current); + timer.current = null; + }; + const show = () => { + cancelClose(); + setOpen(true); + }; + const scheduleClose = () => { + cancelClose(); + timer.current = setTimeout(() => { + if (!panel.current?.contains(document.activeElement)) setOpen(false); + }, 150); + }; + useEffect( + () => () => { + if (timer.current !== null) clearTimeout(timer.current); + }, + [], + ); + useLayoutEffect(() => { + if (!open || !overflow.length) return; + const update = () => { + const anchor = trigger.current?.getBoundingClientRect(); + if (!anchor) return; + const width = panel.current?.getBoundingClientRect().width ?? 200; + const height = panel.current?.scrollHeight ?? 320; + const below = window.innerHeight - anchor.bottom - 12; + const above = anchor.top - 12; + // 优先上展,留出卡片预览;窗口顶边空间不足时才向下避让。 + const down = above < 80 && below > above; + const maxHeight = Math.max(40, Math.min(360, down ? below : above)); + setPosition({ + left: Math.max( + 8, + Math.min(anchor.right - width, window.innerWidth - width - 8), + ), + top: down + ? anchor.bottom + 4 + : Math.max(8, anchor.top - Math.min(height, maxHeight) - 4), + maxHeight, + }); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [open, overflow.length, children]); + useEffect(() => { + if (!open) return; + const outside = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !trigger.current?.contains(event.target) && + !panel.current?.contains(event.target) + ) { + setOpen(false); + } + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('pointerdown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('pointerdown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + if (!overflow.length) return <>{children}; + return ( + <> + {primary} + + {open + ? createPortal( +
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + if ((event.target as Element).closest('button:not(:disabled)')) + setOpen(false); + }} + onBlur={(event) => { + if ( + !event.currentTarget.contains(event.relatedTarget) && + event.relatedTarget !== trigger.current + ) + setOpen(false); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + } + if ( + ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) + ) { + event.preventDefault(); + const buttons = Array.from( + event.currentTarget.querySelectorAll( + 'button:not(:disabled)', + ), + ); + const index = buttons.indexOf( + document.activeElement as HTMLButtonElement, + ); + const next = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? buttons.length - 1 + : (index + + (event.key === 'ArrowDown' ? 1 : -1) + + buttons.length) % + buttons.length; + buttons[next]?.focus(); + } + }} + > + {overflow} +
, + document.body, + ) + : null} + + ); +} diff --git a/packages/shared/src/components/index.ts b/packages/shared/src/components/index.ts index f2b1cee94..327a40757 100644 --- a/packages/shared/src/components/index.ts +++ b/packages/shared/src/components/index.ts @@ -220,6 +220,8 @@ export { Textarea } from './ui/textarea'; // existing application adapters while keeping the public API product-neutral. export type { AgentMessageTone } from './AgentMessageContent'; export { AgentMessageContent } from './AgentMessageContent'; +export { CanvasCardCornerActions } from './CanvasCardCornerActions'; +export { OverflowActions } from './OverflowActions'; export type { ButtonProps as PlatformButtonProps, SwitchProps as PlatformSwitchProps, diff --git a/packages/shared/src/components/styles.css b/packages/shared/src/components/styles.css index 2c969af7d..aa9301eec 100644 --- a/packages/shared/src/components/styles.css +++ b/packages/shared/src/components/styles.css @@ -1310,3 +1310,126 @@ textarea.genarrative-ui-text-field__control { border-radius: 1.25rem 1.25rem 0 0; } } +/* 画布卡片角标:缩放由宿主传入,两个入口保持一致的视觉尺寸。 */ +.shared-canvas-card-corners { + position: absolute; + top: 6px; + right: 6px; + z-index: 3; + display: flex; + align-items: center; + gap: 4px; + max-width: calc(100% - 12px); + transform: scale( + var( + --image-canvas-editor-inverse-scale, + var(--genarrative-image-canvas-inverse-scale, 1) + ) + ); + transform-origin: top right; +} +.shared-canvas-card-kind { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 8px; + border: 1px solid rgb(255 255 255 / 72%); + border-radius: 999px; + background: rgb(199 101 61 / 92%); + color: #fff; + font-size: 11px; + font-weight: 850; + line-height: 1; +} +.shared-canvas-card-kind[role='button'] { + cursor: pointer; +} +.shared-canvas-card-info { + display: grid; + flex: 0 0 22px; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid rgb(255 255 255 / 42%); + border-radius: 50%; + background: rgb(199 101 61 / 92%); + color: #fff; + cursor: pointer; +} +.shared-canvas-card-info:hover, +.shared-canvas-card-kind[role='button']:hover { + background: #b95d3a; +} +.shared-canvas-card-corners [role='button']:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} +.shared-overflow-trigger { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + cursor: pointer; +} +.shared-overflow-trigger:hover, +.shared-overflow-trigger[aria-expanded='true'] { + background: #f4e7df; +} +.shared-overflow-panel { + position: fixed; + z-index: 1500; + display: flex; + flex-direction: column; + gap: 3px; + width: 200px; + max-width: calc(100vw - 16px); + overflow-y: auto; + overscroll-behavior: contain; + padding: 6px; + border: 1px solid #e7d5c8; + border-radius: 9px; + background: #fff; + color: #4b3026; + box-shadow: 0 10px 28px rgb(52 30 22 / 18%); +} +.shared-overflow-panel > button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + flex: 0 0 auto; + width: 100%; + min-height: 34px; + padding: 6px 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.shared-overflow-panel > button:hover, +.shared-overflow-panel > button:focus-visible { + background: #f4e7df; +} +.shared-overflow-panel > button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.shared-overflow-panel + > button.genarrative-image-canvas__chrome-button:not( + .genarrative-image-canvas__chrome-button--with-label + )::after { + content: attr(aria-label); +} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 8b5a462fc..a2f6a607e 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -70,6 +70,50 @@ function renderSelectedToolbar( } describe('ImageCanvasSelectedLayerToolbarView', () => { + it('AGC 收纳包含宿主末组,换选后关闭旧资源更多菜单', () => { + const props = renderSelectedToolbar({ + maxVisibleActions: 5, + supportedActions: new Set([ + 'quick-edit', + 'character-animation', + 'download', + ]), + downloadLabel: '导出', + extraActions: ( + <> + + + + + ), + endActions: , + }); + const toolbar = screen.getByRole('toolbar'); + expect( + within(toolbar) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['快速编辑', '生成动画', '引用', '编辑标签', '重命名', '更多 ▴']); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['导出', '删除素材']); + fireEvent.click(within(group).getByRole('button', { name: '导出' })); + expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer); + cleanup(); + const view = render(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + view.rerender( + , + ); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + }); it('常规动作与末组分别分隔,导出紧邻删除且位于删除之前', () => { const props = renderSelectedToolbar({ supportedActions: new Set(['quick-edit', 'download']), diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 6587025c0..f61909628 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,4 +1,5 @@ import { CanvasChromeButton } from '@genarrative/image-canvas-react'; +import { OverflowActions } from '@genarrative/shared/components'; import { Crop, Download, @@ -38,6 +39,7 @@ export type ImageCanvasSelectedToolbarAction = | 'download'; type ImageCanvasSelectedLayerToolbarViewProps = { + maxVisibleActions?: number; /** * 宿主显式声明的可用动作集合。 * @@ -78,6 +80,7 @@ function hasToolbarActions(actions: ReactNode): boolean { } export function ImageCanvasSelectedLayerToolbarView({ + maxVisibleActions, supportedActions = null, extraActions, endActions, @@ -148,22 +151,24 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="素材工具栏" onPointerDown={(event) => event.stopPropagation()} > - {canRedraw ? ( - } - onClick={() => onOpenRedrawPanel(selectedLayer)} - > - 改造 - - ) : null} - {canRedraw && hasExtraActions ? divider : null} - {extraActions} - {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} - {downloadAction} - {endActions} + + {canRedraw ? ( + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + ) : null} + {canRedraw && hasExtraActions ? divider : null} + {extraActions} + {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} + {downloadAction} + {endActions} + ); } @@ -208,170 +213,172 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="图片工具栏" onPointerDown={(event) => event.stopPropagation()} > - {showQuickEdit ? ( - } - onClick={() => onOpenQuickEditPanel(selectedLayer)} - > - 快速编辑 - - ) : null} - {showQuickEdit && hasEditingActions ? divider : null} - {showCropExpand ? ( - onOpenCropExpandPanel(selectedLayer)} - /> - ) : null} - {showRemoveBackground ? ( - onRemoveBackground(selectedLayer)} - /> - ) : null} - {showPerfectPixel ? ( - - ) : ( - - ) - } - // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 - // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 - // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 - // 与相邻的拆分图集按钮保持同一套门禁。 - disabled={ - isPersistingAssetKind || - isPerfectPixelProcessing || - isPerfectPixelPendingConfirmation - } - aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} - onClick={() => onPerfectPixel(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isPerfectPixelProcessing - ? '处理中' - : isPerfectPixelPendingConfirmation - ? '待确认' - : '完美像素'} - - - ) : null} - {showSplitIconSpritesheet ? ( - - ) : ( - - ) - } - disabled={isPersistingAssetKind || isSplittingIconSpritesheet} - aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} - onClick={() => onSplitIconSpritesheet(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isSplittingIconSpritesheet - ? '拆图中' - : '拆分图集'} - - - ) : null} - {showExtractUiDesign ? ( - } - onClick={() => onExtractUiDesignAssets(selectedLayer)} - > - 提取素材 - - ) : null} - {showCharacterAnimation ? ( - } - onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} - > - 生成动画 - - ) : null} - {canRedraw ? ( - <> - {showQuickEdit || hasEditingActions ? divider : null} + + {showQuickEdit ? ( } - onClick={() => onOpenRedrawPanel(selectedLayer)} + label="快速编辑" + title="快速编辑" + icon={} + onClick={() => onOpenQuickEditPanel(selectedLayer)} > - 改造 + 快速编辑 - - ) : null} - {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions - ? divider - : null} - {extraActions} - {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && - hasEndActions - ? divider - : null} - {downloadAction} - {endActions} + ) : null} + {showQuickEdit && hasEditingActions ? divider : null} + {showCropExpand ? ( + onOpenCropExpandPanel(selectedLayer)} + /> + ) : null} + {showRemoveBackground ? ( + onRemoveBackground(selectedLayer)} + /> + ) : null} + {showPerfectPixel ? ( + + ) : ( + + ) + } + // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 + // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 + // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 + // 与相邻的拆分图集按钮保持同一套门禁。 + disabled={ + isPersistingAssetKind || + isPerfectPixelProcessing || + isPerfectPixelPendingConfirmation + } + aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} + onClick={() => onPerfectPixel(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isPerfectPixelProcessing + ? '处理中' + : isPerfectPixelPendingConfirmation + ? '待确认' + : '完美像素'} + + + ) : null} + {showSplitIconSpritesheet ? ( + + ) : ( + + ) + } + disabled={isPersistingAssetKind || isSplittingIconSpritesheet} + aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} + onClick={() => onSplitIconSpritesheet(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isSplittingIconSpritesheet + ? '拆图中' + : '拆分图集'} + + + ) : null} + {showExtractUiDesign ? ( + } + onClick={() => onExtractUiDesignAssets(selectedLayer)} + > + 提取素材 + + ) : null} + {showCharacterAnimation ? ( + } + onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} + > + 生成动画 + + ) : null} + {canRedraw ? ( + <> + {showQuickEdit || hasEditingActions ? divider : null} + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + + ) : null} + {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions + ? divider + : null} + {extraActions} + {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && + hasEndActions + ? divider + : null} + {downloadAction} + {endActions} + ); } diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index ebc0b3b75..bd03cd7ab 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -814,7 +814,7 @@ describe('ImageCanvasWorldView', () => { expect( ( - within(layerButton).getByText('角色') as HTMLElement + within(layerButton).getByText('角色').parentElement as HTMLElement ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -825,6 +825,7 @@ describe('ImageCanvasWorldView', () => { expect( within(layerButton) .getByRole('button', { name: '查看角色主图图片信息' }) + .parentElement! .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -1118,12 +1119,8 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(badge.className).toContain( - 'image-canvas-editor__kind-badge--beside-info', - ); - expect(metadataButton.className).not.toContain( - 'image-canvas-editor__metadata-corner--beside-kind', - ); + expect(badge.parentElement?.className).toBe('shared-canvas-card-corners'); + expect(badge.nextElementSibling).toBe(metadataButton); }); it('keeps the layer type menu scrollable and closes it when clicking outside', () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 42315907a..a9e1e9622 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -3,13 +3,13 @@ import { LayerRenderer, SelectionOverlay, } from '@genarrative/image-canvas-react'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AppWindow, Clapperboard, ClipboardList, Grid2X2, ImageIcon, - Info, Megaphone, Mountain, Music, @@ -44,7 +44,6 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; -import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { @@ -367,17 +366,6 @@ function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { event.currentTarget.click(); } -function handleLayerTagKeyboardActivation( - event: ReactKeyboardEvent, -) { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.click(); -} - function stopMediaControlKeyPropagation( event: ReactKeyboardEvent, ) { @@ -1012,38 +1000,19 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ mediaTransform={mediaTransform} /> )} - {kindLabel ? ( - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - handlers.setOpenLayerKindMenuId((currentId) => - currentId === layer.id ? null : layer.id, - ); - }} - onKeyDown={handleLayerTagKeyboardActivation} - > - {kindLabel} - - ) : null} - } + { - event.stopPropagation(); - handlers.onOpenLayerMetadata(layer); - }} - onPointerDown={(event) => event.stopPropagation()} + onKindClick={() => + handlers.setOpenLayerKindMenuId((currentId) => + currentId === layer.id ? null : layer.id, + ) + } + onInfoClick={() => handlers.onOpenLayerMetadata(layer)} /> {isHovered && layer.mediaType !== 'audio' ? (