WIP: AGC 资源画布与替换改造 V3.0 #316
@@ -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';
|
||||
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<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(
|
||||
new Map<string, { left: number; top: number }>(),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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