资源画布浮层接上美术画布的关闭时机:空白/Esc 清焦点,快速编辑点外部即收
- 新增 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 关闭。
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ import type {
|
|||||||
} from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
|
} from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||||
import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
|
import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
|
||||||
import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView';
|
import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView';
|
||||||
|
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
|
||||||
import {
|
import {
|
||||||
LocalGamePreviewFrame,
|
LocalGamePreviewFrame,
|
||||||
type LocalGamePreviewInspectSelection,
|
type LocalGamePreviewInspectSelection,
|
||||||
@@ -96,6 +97,12 @@ import {
|
|||||||
resolveResourceCanvasPanelEntries,
|
resolveResourceCanvasPanelEntries,
|
||||||
resolveSelectedPanelEntries,
|
resolveSelectedPanelEntries,
|
||||||
} from '../../features/resource-canvas/resourceCanvasAssetTransferModel';
|
} from '../../features/resource-canvas/resourceCanvasAssetTransferModel';
|
||||||
|
import {
|
||||||
|
canDismissResourceCanvasQuickEdit,
|
||||||
|
isResourceCanvasInteractionTarget,
|
||||||
|
resolveResourceCanvasFocusEscapeActive,
|
||||||
|
resolveResourceCanvasQuickEditDismissOpen,
|
||||||
|
} from '../../features/resource-canvas/resourceCanvasFocusModel';
|
||||||
import {
|
import {
|
||||||
isResourceCanvasGenerationAvailable,
|
isResourceCanvasGenerationAvailable,
|
||||||
resourceCanvasGenerationOption,
|
resourceCanvasGenerationOption,
|
||||||
@@ -1373,6 +1380,102 @@ export default function ProjectDevelopmentView({
|
|||||||
normalizedAssetId: string | null;
|
normalizedAssetId: string | null;
|
||||||
expectedProjectRevision: number | null;
|
expectedProjectRevision: number | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
/** 快速编辑面板的最新状态:清焦点回调只在事件里读,不随面板状态反复重建。 */
|
||||||
|
const quickEditPanelRef = useRef<QuickEditPanelState | null>(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(
|
const resourceSectionScrollPositionsRef = useRef(
|
||||||
new Map<string, { left: number; top: number }>(),
|
new Map<string, { left: number; top: number }>(),
|
||||||
);
|
);
|
||||||
@@ -4038,11 +4141,7 @@ export default function ProjectDevelopmentView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (
|
if (isResourceCanvasInteractionTarget(target)) {
|
||||||
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',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resourceBookTransitionControllerRef.current.settle();
|
resourceBookTransitionControllerRef.current.settle();
|
||||||
@@ -4059,6 +4158,10 @@ export default function ProjectDevelopmentView({
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 与美术画布一致:空白处的左键 pointerdown 先清画布焦点,选中工具条与快速编辑
|
||||||
|
// 浮层随之收起。这里不能等 click——下面的 preventDefault() 会掐掉指针的兼容鼠标
|
||||||
|
// 事件,document 上的点外部关闭收不到这次点击。
|
||||||
|
clearResourceCanvasFocus();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
setResourceCanvasMarquee(
|
setResourceCanvasMarquee(
|
||||||
@@ -4069,7 +4172,7 @@ export default function ProjectDevelopmentView({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[activePageCategory],
|
[activePageCategory, clearResourceCanvasFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResourceCanvasPointerMove = useCallback(
|
const handleResourceCanvasPointerMove = useCallback(
|
||||||
@@ -4913,12 +5016,6 @@ export default function ProjectDevelopmentView({
|
|||||||
[canvasResources],
|
[canvasResources],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeResourceQuickEditPanel = useCallback(() => {
|
|
||||||
setQuickEditPanel(null);
|
|
||||||
setQuickEditSourceLayer(null);
|
|
||||||
resourceQuickEditRequestRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 快速编辑:先按后端真实门禁把任务产物正规化成正式素材,再走资源派生产出一张新素材。
|
* 快速编辑:先按后端真实门禁把任务产物正规化成正式素材,再走资源派生产出一张新素材。
|
||||||
* 源素材(文件与 manifest 条目)保持不变。
|
* 源素材(文件与 manifest 条目)保持不变。
|
||||||
@@ -5202,20 +5299,6 @@ export default function ProjectDevelopmentView({
|
|||||||
selectedResource?.subtype === 'UI' ||
|
selectedResource?.subtype === 'UI' ||
|
||||||
selectedResource?.subtype === 'ui-prototype';
|
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
|
const selectedToolbarStyle = selectedResourceLayer
|
||||||
? resolveSelectedToolbarStyle({
|
? resolveSelectedToolbarStyle({
|
||||||
selectedLayer: selectedResourceLayer,
|
selectedLayer: selectedResourceLayer,
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<div class="game-resource-canvas">
|
||||||
|
<div class="game-resource-card"><button type="button">卡片按钮</button></div>
|
||||||
|
<div class="game-resource-book-scene-titlebar"></div>
|
||||||
|
<div class="game-resource-book-zoom"><button type="button">放大</button></div>
|
||||||
|
<div class="game-resource-canvas-content"><span id="blank">空白</span></div>
|
||||||
|
<div class="image-canvas-editor__floating-toolbar"><button type="button">快速编辑</button></div>
|
||||||
|
<div class="image-canvas-editor__generation-composer"><div id="prompt">提示词</div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const query = (selector: string) =>
|
||||||
|
document.querySelector<HTMLElement>(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<keyof typeof closed>) {
|
||||||
|
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<HTMLDivElement | null>(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 (
|
||||||
|
<div>
|
||||||
|
<div ref={canvasRef} data-testid="resource-canvas">
|
||||||
|
{selected ? <div role="toolbar" aria-label="选中工具条" /> : null}
|
||||||
|
{panelOpen ? <div role="dialog" aria-label="快速编辑浮层面板" /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('资源画布浮层关闭时机', () => {
|
||||||
|
test('点画布浮层内部不关闭,点画布以外立即关闭', () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(<ResourceCanvasFocusHarness onDismiss={onDismiss} />);
|
||||||
|
|
||||||
|
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(<ResourceCanvasFocusHarness onDismiss={onDismiss} />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ResourceCanvasFocusHarness
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
initialPanelOpen={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByRole('toolbar', { name: '选中工具条' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只有选中工具条时点画布以外不动选中', () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(
|
||||||
|
<ResourceCanvasFocusHarness
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
initialPanelOpen={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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<QuickEditPanelState | null>(initialPanel);
|
||||||
|
|
||||||
|
if (!panel) {
|
||||||
|
return <output aria-label="面板状态">closed</output>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ImageCanvasQuickEditPanelView
|
||||||
|
panel={panel}
|
||||||
|
style={{ left: 10, top: 20 }}
|
||||||
|
setQuickEditPanel={setPanel}
|
||||||
|
onSubmit={() => undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('快速编辑浮层的下拉弹层', () => {
|
||||||
|
test('模型与比例弹层都能点外部收起', () => {
|
||||||
|
render(
|
||||||
|
<QuickEditPanelHarness
|
||||||
|
initialPanel={createResourceQuickEditPanelDraft(
|
||||||
|
RESOURCE_FOCUS_SOURCE_LAYER,
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QuickEditPanelHarness
|
||||||
|
initialPanel={createResourceQuickEditPanelDraft(
|
||||||
|
RESOURCE_FOCUS_SOURCE_LAYER,
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user