From f49ff61ddc76d7b96b936bdb79040c60221704d6 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 18:58:15 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83=E6=B5=AE?= =?UTF-8?q?=E5=B1=82=E6=8E=A5=E4=B8=8A=E7=BE=8E=E6=9C=AF=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=9A=84=E5=85=B3=E9=97=AD=E6=97=B6=E6=9C=BA=EF=BC=9A=E7=A9=BA?= =?UTF-8?q?=E7=99=BD/Esc=20=E6=B8=85=E7=84=A6=E7=82=B9=EF=BC=8C=E5=BF=AB?= =?UTF-8?q?=E9=80=9F=E7=BC=96=E8=BE=91=E7=82=B9=E5=A4=96=E9=83=A8=E5=8D=B3?= =?UTF-8?q?=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 resourceCanvasFocusModel:把「这次 pointerdown 要不要清画布焦点」「快速编辑浮层是否接管点外部关闭」「宿主 Escape 是否归资源画布」抽成纯判据,逐条对齐美术画布 clearCanvasFocus() / closeTransientEditorPanels() / clearCanvasSelection()。 - index.tsx 画布空白左键 pointerdown 调 clearResourceCanvasFocus():清空选中(选中工具条随之收起)并关闭快速编辑浮层,与美术画布 useImageCanvasStageInteractions 的空白分支一致;中键/空格平移分支不清,避免平移顺手丢选中。 - 快速编辑浮层复用共享 hook useImageCanvasFloatingOptionDismiss:document 上接管 click 与 Escape,点整个资源画布以外即关,点边界内(含模型/比例下拉 .image-canvas-editor__portal-menu)保留;宿主弹窗(资源面板/生成素材/分类与标签/重命名/恢复队列)开着时不接管,避免清掉资源面板共用的多选。 - 选中工具条补 window 级 Escape:与美术画布 clearCanvasSelection() 同口径清空选中;下拉弹层打开时共享 hook 在 document 上截断,浮层优先收内层。 - 生成为中的快速编辑面板不关(status === 'generating'),保留「修改中」状态与失败重试入口。 - 新增定向测试:交互目标判据、生成中不关、宿主弹窗让位、点边界外关闭、Esc 两态关闭、只有工具条时点外部不清选中、模型/比例下拉点外部与 Esc 关闭。 --- .../resourceCanvasFocusModel.ts | 119 ++++++ .../src/view/project-development/index.tsx | 135 ++++-- .../resourceCanvasFloatingDismiss.test.tsx | 390 ++++++++++++++++++ 3 files changed, 618 insertions(+), 26 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.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 new file mode 100644 index 000000000..a5db859f6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -0,0 +1,119 @@ +/** + * 资源画布浮层的关闭判据。 + * + * 判据全部对齐网页端美术画布(`packages/image-canvas-react/src/useImageCanvasStageController.ts` + * 的 `clearCanvasFocus()` 与 `src/components/image-editor/useImageCanvasKeyboardShortcuts.ts` + * 的 Escape 分支),AGC 不再自带第二套口径: + * + * - 画布空白处左键 pointerdown → 清焦点(清空选中 + 关闭快速编辑浮层); + * - 点在资源卡、交互控件或画布浮层里 → 不清焦点; + * - 选中变化 / 切项目 / 切模式 → 由宿主既有的重置逻辑负责。 + */ + +/** + * 落在这些元素里的 pointerdown 不清画布焦点。 + * + * 与美术画布「点在图层上不清焦点、点在空白/world 上才清」同口径:AGC 的资源卡 + * 等价于图层,工具条与快速编辑面板等价于浮层,其余交互控件(按钮、输入、缩放、 + * 页签)都不该顺手把选中清掉。 + */ +export const RESOURCE_CANVAS_INTERACTION_SELECTOR = [ + 'button', + 'input', + 'textarea', + 'select', + 'a', + '.game-resource-card', + '.game-resource-book-scene-titlebar', + '.game-resource-book-zoom', + '.image-canvas-editor__floating-toolbar', + '.image-canvas-editor__generation-composer', +].join(', '); + +/** 这一次画布 pointerdown 要不要清焦点。 */ +export function isResourceCanvasInteractionTarget( + target: Element | null | undefined, +): boolean { + return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR)); +} + +/** + * 快速编辑面板是否可以被清焦点顺手关掉。 + * + * 对齐美术画布 `closeTransientEditorPanels()`:生成中的面板不关,否则用户看不到 + * 「修改中」状态,也拿不到失败后的重试入口。 + */ +export function canDismissResourceCanvasQuickEdit( + panel: { readonly status: string } | null | undefined, +): boolean { + return panel?.status !== 'generating'; +} + +/** 资源画布上的 AGC 自有弹层:它们开着时画布浮层不参与「点外部关闭」。 */ +export type ResourceCanvasHostOverlayState = { + readonly isResourcePanelOpen: boolean; + readonly isGenerationPanelOpen: boolean; + readonly isClassificationPanelOpen: boolean; + readonly isRenameDialogOpen: boolean; + readonly isRecoveryPanelOpen: boolean; +}; + +export function isResourceCanvasHostOverlayOpen( + overlay: ResourceCanvasHostOverlayState, +): boolean { + return ( + overlay.isResourcePanelOpen || + overlay.isGenerationPanelOpen || + overlay.isClassificationPanelOpen || + overlay.isRenameDialogOpen || + overlay.isRecoveryPanelOpen + ); +} + +/** + * 快速编辑浮层是否接管「点外部 / Esc 关闭」。 + * + * 只有它自己开着时才接管,而且是唯一的取消条件:AGC 弹层开着时不接管——资源面板的 + * 多选直接读写画布那一份 `selectedResourceIds`,此刻清选中会把用户刚勾的资源清空。 + * + * 选中工具条不在这里,它跟美术画布一样只随选中存在:单独选中一个资源卡不会因为 + * 点了一下对话区就丢选中(资源面板下载、版本绑定高亮都挂在同一份选中上)。 + */ +export function resolveResourceCanvasQuickEditDismissOpen({ + isCanvasVisible, + isQuickEditPanelOpen, + hostOverlay, +}: { + isCanvasVisible: boolean; + isQuickEditPanelOpen: boolean; + hostOverlay: ResourceCanvasHostOverlayState; +}): boolean { + return ( + isCanvasVisible && + isQuickEditPanelOpen && + !isResourceCanvasHostOverlayOpen(hostOverlay) + ); +} + +/** + * 宿主自己的 Escape 是否归资源画布。 + * + * 对齐美术画布:Escape 走 `closeTransientEditorPanels()` + `clearCanvasSelection()`, + * 既收浮层也清选中。AGC 自带弹窗开着时让位给弹窗自己(Esc 只关弹窗)。 + */ +export function resolveResourceCanvasFocusEscapeActive({ + isCanvasVisible, + hasSelectedResource, + isQuickEditPanelOpen, + hostOverlay, +}: { + isCanvasVisible: boolean; + hasSelectedResource: boolean; + isQuickEditPanelOpen: boolean; + hostOverlay: ResourceCanvasHostOverlayState; +}): boolean { + if (!isCanvasVisible || isResourceCanvasHostOverlayOpen(hostOverlay)) { + return false; + } + return hasSelectedResource || isQuickEditPanelOpen; +} 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 c124d4f71..c85e0a5cd 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 @@ -78,6 +78,7 @@ import type { } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; +import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; import { LocalGamePreviewFrame, type LocalGamePreviewInspectSelection, @@ -96,6 +97,12 @@ import { resolveResourceCanvasPanelEntries, resolveSelectedPanelEntries, } from '../../features/resource-canvas/resourceCanvasAssetTransferModel'; +import { + canDismissResourceCanvasQuickEdit, + isResourceCanvasInteractionTarget, + resolveResourceCanvasFocusEscapeActive, + resolveResourceCanvasQuickEditDismissOpen, +} from '../../features/resource-canvas/resourceCanvasFocusModel'; import { isResourceCanvasGenerationAvailable, resourceCanvasGenerationOption, @@ -1373,6 +1380,102 @@ export default function ProjectDevelopmentView({ normalizedAssetId: string | null; expectedProjectRevision: number | null; } | null>(null); + /** 快速编辑面板的最新状态:清焦点回调只在事件里读,不随面板状态反复重建。 */ + const quickEditPanelRef = useRef(null); + quickEditPanelRef.current = quickEditPanel; + + const closeResourceQuickEditPanel = useCallback(() => { + setQuickEditPanel(null); + setQuickEditSourceLayer(null); + resourceQuickEditRequestRef.current = null; + }, []); + + /** + * 清资源画布焦点:对齐美术画布 `clearCanvasFocus()`。 + * + * 美术画布在画布空白 pointerdown 与 Escape 两处都会走这一步——清空选中让选中工具条 + * 收起,并关掉快速编辑浮层。资源画布保持同一套时机:点在资源卡或浮层里不清, + * 点画布空白、按 Esc 才清。生成中的面板不关,与美术画布 + * `closeTransientEditorPanels()` 的 `status === 'generating'` 判断一致。 + */ + const clearResourceCanvasFocus = useCallback(() => { + setSelectedResourceIds([]); + if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) { + return; + } + closeResourceQuickEditPanel(); + }, [closeResourceQuickEditPanel]); + + /** + * 快速编辑浮层的「点外部 / Esc 关闭」。 + * + * 直接复用美术画布的 `useImageCanvasFloatingOptionDismiss`,不另写判据:它在 + * document 上同时接管 click 与 Escape,点在边界外即关、点在边界内(含 + * `.image-canvas-editor__portal-menu` 弹出层,例如模型 / 比例下拉)保留。 + * + * 边界取整个资源画布管理器:面板内部的点(改提示词、切选项)不算外部;点到画布 + * 以外(顶部工具条、对话区、左侧导航)立即收起。 + */ + useImageCanvasFloatingOptionDismiss({ + isOpen: resolveResourceCanvasQuickEditDismissOpen({ + isCanvasVisible: mode === 'resources' && !uiEditorRoute, + isQuickEditPanelOpen: quickEditPanel !== null, + hostOverlay: { + isResourcePanelOpen: resourcePanelOpen, + isGenerationPanelOpen: resourceGenerationOpen, + isClassificationPanelOpen: resourceClassificationAssetId !== null, + isRenameDialogOpen: resourceRenameAssetId !== null, + isRecoveryPanelOpen: resourceRecoveryPanelOpen, + }, + }), + boundaryRefs: [resourceBookManagerRef], + onDismiss: clearResourceCanvasFocus, + }); + + /** + * 选中工具条的「Esc 关闭」。 + * + * 选中工具条跟随选中存在,所以它没有独立的开关;Esc 与美术画布一样走 + * `clearCanvasSelection()` 那一半:清空选中,工具条随之收起。挂在 window 上, + * 快速编辑里的下拉弹层打开时会被共享 hook 在 document 上截断,符合 + * 「浮层打开时 Escape 归浮层所有」的既有口径。 + */ + useEffect(() => { + if ( + !resolveResourceCanvasFocusEscapeActive({ + isCanvasVisible: mode === 'resources' && !uiEditorRoute, + hasSelectedResource: selectedResourceIds.length > 0, + isQuickEditPanelOpen: quickEditPanel !== null, + hostOverlay: { + isResourcePanelOpen: resourcePanelOpen, + isGenerationPanelOpen: resourceGenerationOpen, + isClassificationPanelOpen: resourceClassificationAssetId !== null, + isRenameDialogOpen: resourceRenameAssetId !== null, + isRecoveryPanelOpen: resourceRecoveryPanelOpen, + }, + }) + ) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + clearResourceCanvasFocus(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + clearResourceCanvasFocus, + mode, + quickEditPanel, + resourceClassificationAssetId, + resourceGenerationOpen, + resourcePanelOpen, + resourceRecoveryPanelOpen, + resourceRenameAssetId, + selectedResourceIds, + uiEditorRoute, + ]); const resourceSectionScrollPositionsRef = useRef( new Map(), ); @@ -4038,11 +4141,7 @@ export default function ProjectDevelopmentView({ return; } const target = event.target as HTMLElement; - if ( - target.closest( - 'button, input, textarea, select, a, .game-resource-card, .game-resource-book-scene-titlebar, .game-resource-book-zoom, .image-canvas-editor__floating-toolbar, .image-canvas-editor__generation-composer', - ) - ) { + if (isResourceCanvasInteractionTarget(target)) { return; } resourceBookTransitionControllerRef.current.settle(); @@ -4059,6 +4158,10 @@ export default function ProjectDevelopmentView({ }; return; } + // 与美术画布一致:空白处的左键 pointerdown 先清画布焦点,选中工具条与快速编辑 + // 浮层随之收起。这里不能等 click——下面的 preventDefault() 会掐掉指针的兼容鼠标 + // 事件,document 上的点外部关闭收不到这次点击。 + clearResourceCanvasFocus(); event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); setResourceCanvasMarquee( @@ -4069,7 +4172,7 @@ export default function ProjectDevelopmentView({ }), ); }, - [activePageCategory], + [activePageCategory, clearResourceCanvasFocus], ); const handleResourceCanvasPointerMove = useCallback( @@ -4913,12 +5016,6 @@ export default function ProjectDevelopmentView({ [canvasResources], ); - const closeResourceQuickEditPanel = useCallback(() => { - setQuickEditPanel(null); - setQuickEditSourceLayer(null); - resourceQuickEditRequestRef.current = null; - }, []); - /** * 快速编辑:先按后端真实门禁把任务产物正规化成正式素材,再走资源派生产出一张新素材。 * 源素材(文件与 manifest 条目)保持不变。 @@ -5202,20 +5299,6 @@ export default function ProjectDevelopmentView({ selectedResource?.subtype === 'UI' || selectedResource?.subtype === 'ui-prototype'; - useEffect(() => { - if (!quickEditPanel) { - return undefined; - } - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - closeResourceQuickEditPanel(); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [closeResourceQuickEditPanel, quickEditPanel]); - const selectedToolbarStyle = selectedResourceLayer ? resolveSelectedToolbarStyle({ selectedLayer: selectedResourceLayer, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx new file mode 100644 index 000000000..5332ef887 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -0,0 +1,390 @@ +// @vitest-environment jsdom +import type { CanvasLayer } from '@genarrative/image-canvas-core'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { QuickEditPanelState } from '../../../src/components/image-editor/ImageCanvasEditorTypes'; +import { ImageCanvasQuickEditPanelView } from '../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; +import { useImageCanvasFloatingOptionDismiss } from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import { + canDismissResourceCanvasQuickEdit, + isResourceCanvasHostOverlayOpen, + isResourceCanvasInteractionTarget, + resolveResourceCanvasFocusEscapeActive, + resolveResourceCanvasQuickEditDismissOpen, +} from '../src/features/resource-canvas/resourceCanvasFocusModel'; +import { createResourceQuickEditPanelDraft } from '../src/features/resource-canvas/resourceCanvasQuickEditModel'; + +afterEach(() => { + cleanup(); +}); + +const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = { + id: 'resource-1', + resourceId: 'resource-1', + title: '封面图', + src: '/cover.png', + x: 0, + y: 0, + width: 512, + height: 512, + originalWidth: 1024, + originalHeight: 1024, + zIndex: 1, + sourceType: 'upload', +}; + +describe('resourceCanvasFocusModel', () => { + test('点在资源卡、交互控件与画布浮层里时不清画布焦点', () => { + document.body.innerHTML = ` +
+
+
+
+
空白
+
+
提示词
+
+ `; + + const query = (selector: string) => + document.querySelector(selector); + + expect(isResourceCanvasInteractionTarget(query('#blank'))).toBe(false); + expect( + isResourceCanvasInteractionTarget(query('.game-resource-card')), + ).toBe(true); + expect( + isResourceCanvasInteractionTarget(query('.game-resource-card button')), + ).toBe(true); + expect( + isResourceCanvasInteractionTarget( + query('.game-resource-book-scene-titlebar'), + ), + ).toBe(true); + expect( + isResourceCanvasInteractionTarget(query('.game-resource-book-zoom')), + ).toBe(true); + expect( + isResourceCanvasInteractionTarget( + query('.image-canvas-editor__floating-toolbar'), + ), + ).toBe(true); + expect( + isResourceCanvasInteractionTarget( + query('.image-canvas-editor__floating-toolbar button'), + ), + ).toBe(true); + expect( + isResourceCanvasInteractionTarget( + query('.image-canvas-editor__generation-composer'), + ), + ).toBe(true); + expect(isResourceCanvasInteractionTarget(query('#prompt'))).toBe(true); + expect(isResourceCanvasInteractionTarget(null)).toBe(false); + + document.body.innerHTML = ''; + }); + + test('快速编辑生成中不参与清焦点', () => { + expect(canDismissResourceCanvasQuickEdit(null)).toBe(true); + expect(canDismissResourceCanvasQuickEdit({ status: 'idle' })).toBe(true); + expect(canDismissResourceCanvasQuickEdit({ status: 'failed' })).toBe(true); + expect(canDismissResourceCanvasQuickEdit({ status: 'generating' })).toBe( + false, + ); + }); + + test('AGC 自有弹层开着时画布浮层不接管点外部关闭', () => { + const closed = { + isResourcePanelOpen: false, + isGenerationPanelOpen: false, + isClassificationPanelOpen: false, + isRenameDialogOpen: false, + isRecoveryPanelOpen: false, + }; + expect(isResourceCanvasHostOverlayOpen(closed)).toBe(false); + for (const key of Object.keys(closed) as Array) { + expect(isResourceCanvasHostOverlayOpen({ ...closed, [key]: true })).toBe( + true, + ); + } + }); + + test('画布可见且快速编辑浮层打开时才接管点外部关闭', () => { + const closed = { + isResourcePanelOpen: false, + isGenerationPanelOpen: false, + isClassificationPanelOpen: false, + isRenameDialogOpen: false, + isRecoveryPanelOpen: false, + }; + const base = { + isCanvasVisible: true, + isQuickEditPanelOpen: true, + hostOverlay: closed, + }; + + expect(resolveResourceCanvasQuickEditDismissOpen(base)).toBe(true); + expect( + resolveResourceCanvasQuickEditDismissOpen({ + ...base, + isQuickEditPanelOpen: false, + }), + ).toBe(false); + expect( + resolveResourceCanvasQuickEditDismissOpen({ + ...base, + isCanvasVisible: false, + }), + ).toBe(false); + expect( + resolveResourceCanvasQuickEditDismissOpen({ + ...base, + hostOverlay: { ...closed, isResourcePanelOpen: true }, + }), + ).toBe(false); + }); + + test('宿主 Escape 只在画布有浮层且没有自带弹窗时归资源画布', () => { + const closed = { + isResourcePanelOpen: false, + isGenerationPanelOpen: false, + isClassificationPanelOpen: false, + isRenameDialogOpen: false, + isRecoveryPanelOpen: false, + }; + const base = { + isCanvasVisible: true, + hasSelectedResource: false, + isQuickEditPanelOpen: true, + hostOverlay: closed, + }; + + expect(resolveResourceCanvasFocusEscapeActive(base)).toBe(true); + // 选中工具条没有独立开关:只有选中、没有浮层时 Escape 同样要清选中。 + expect( + resolveResourceCanvasFocusEscapeActive({ + ...base, + isQuickEditPanelOpen: false, + hasSelectedResource: true, + }), + ).toBe(true); + expect( + resolveResourceCanvasFocusEscapeActive({ + ...base, + isQuickEditPanelOpen: false, + hasSelectedResource: false, + }), + ).toBe(false); + expect( + resolveResourceCanvasFocusEscapeActive({ + ...base, + isCanvasVisible: false, + }), + ).toBe(false); + expect( + resolveResourceCanvasFocusEscapeActive({ + ...base, + hostOverlay: { ...closed, isRenameDialogOpen: true }, + }), + ).toBe(false); + }); +}); + +/** + * 与 `project-development/index.tsx` 同一套接线: + * - 共享 hook 只管快速编辑浮层,边界是整个资源画布,点边界外即关; + * - 选中工具条跟随选中存在,Esc 由宿主自己的 window 监听清焦点。 + */ +function ResourceCanvasFocusHarness({ + onDismiss, + initialPanelOpen = true, +}: { + onDismiss: () => void; + initialPanelOpen?: boolean; +}) { + const canvasRef = useRef(null); + const [selected, setSelected] = useState(true); + const [panelOpen, setPanelOpen] = useState(initialPanelOpen); + + const clearFocus = useCallback(() => { + onDismiss(); + setSelected(false); + setPanelOpen(false); + }, [onDismiss]); + + useImageCanvasFloatingOptionDismiss({ + isOpen: panelOpen, + boundaryRefs: [canvasRef], + onDismiss: clearFocus, + }); + + useEffect(() => { + if (!selected && !panelOpen) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + clearFocus(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [clearFocus, panelOpen, selected]); + + return ( +
+
+ {selected ?
: null} + {panelOpen ?
: null} +
+
+ ); +} + +describe('资源画布浮层关闭时机', () => { + test('点画布浮层内部不关闭,点画布以外立即关闭', () => { + const onDismiss = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('toolbar', { name: '选中工具条' })); + fireEvent.click(screen.getByRole('dialog', { name: '快速编辑浮层面板' })); + expect(onDismiss).not.toHaveBeenCalled(); + expect(screen.getByRole('toolbar', { name: '选中工具条' })).toBeTruthy(); + + fireEvent.click(document.body); + + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('toolbar', { name: '选中工具条' })).toBeNull(); + expect( + screen.queryByRole('dialog', { name: '快速编辑浮层面板' }), + ).toBeNull(); + }); + + test('快速编辑浮层开着时 Esc 一并收起工具条', () => { + const onDismiss = vi.fn(); + render(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('toolbar', { name: '选中工具条' })).toBeNull(); + expect( + screen.queryByRole('dialog', { name: '快速编辑浮层面板' }), + ).toBeNull(); + }); + + test('只有选中工具条时 Esc 也收起来', () => { + const onDismiss = vi.fn(); + render( + , + ); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('toolbar', { name: '选中工具条' })).toBeNull(); + }); + + test('只有选中工具条时点画布以外不动选中', () => { + const onDismiss = vi.fn(); + render( + , + ); + + fireEvent.click(document.body); + + // 工具条跟随选中存在:清选中会连带丢资源面板多选与版本绑定高亮,所以点外部不代劳。 + expect(onDismiss).not.toHaveBeenCalled(); + expect(screen.getByRole('toolbar', { name: '选中工具条' })).toBeTruthy(); + }); +}); + +/** 与 AGC 一样只给必要 props:下拉弹层就地渲染,不走 portal。 */ +function QuickEditPanelHarness({ + initialPanel, +}: { + initialPanel: QuickEditPanelState; +}) { + const [panel, setPanel] = useState(initialPanel); + + if (!panel) { + return closed; + } + + return ( +
+ undefined} + /> +
+ ); +} + +describe('快速编辑浮层的下拉弹层', () => { + test('模型与比例弹层都能点外部收起', () => { + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), + ); + expect(screen.getByRole('button', { name: 'gpt-image-2' })).toBeTruthy(); + + // 点面板内、弹层外的提示词区域:只收弹层,不动面板本身。 + fireEvent.click(screen.getByLabelText('快速编辑提示词')); + expect(screen.queryByRole('button', { name: 'gpt-image-2' })).toBeNull(); + expect( + screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), + ).toBeTruthy(); + + fireEvent.click( + screen.getByRole('button', { name: '快速编辑尺寸 1:1·1K' }), + ); + expect(screen.getByRole('button', { name: '比例 4:3' })).toBeTruthy(); + + // 点弹层里的选项只改选区,弹层留着继续选尺寸。 + fireEvent.click(screen.getByRole('button', { name: '比例 4:3' })); + expect( + screen.getByRole('button', { name: '快速编辑尺寸 4:3·1K' }), + ).toBeTruthy(); + + fireEvent.click(screen.getByLabelText('快速编辑提示词')); + expect(screen.queryByRole('button', { name: '比例 4:3' })).toBeNull(); + }); + + test('Esc 收起下拉弹层', () => { + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }), + ); + expect(screen.getByRole('button', { name: 'gpt-image-2' })).toBeTruthy(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.queryByRole('button', { name: 'gpt-image-2' })).toBeNull(); + }); +});