973e965f4f
- 快速编辑与生成动画点提交当帧即关面板(不等 IPC、不留等待态),进度、阶段与结果只由「生成任务」侧栏承载,与图片类生成面板同一口径 - 受理之后的失败不再重开面板:侧栏那条收口为失败 + 提示条,草稿回落成「未完成编辑」等用户重开;只有点击瞬间就失败(名称预检、源素材不在画布、无客户端)留在面板里 - 派生请求身份改为按「入口 + 资源路径」记忆:重开面板再提交时提示词没变就沿用同一 operationId / 幂等键,命中同一 operation 账本;派生成成功后清除 - 删除 canDismissResourceCanvasQuickEdit 与宿主里只为它服务的两块面板状态 ref,clearResourceCanvasFocus 回到「清选中 + 关两块浮层」的直线逻辑 - 三条既有用例按新口径改写为「重开面板再重试,身份不变」;PRD §3.10 与 decision-log 同步
749 lines
25 KiB
TypeScript
749 lines
25 KiB
TypeScript
// @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 { createPortal } from 'react-dom';
|
||
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 {
|
||
isFloatingOverlayWheelEvent,
|
||
useImageCanvasFloatingOptionDismiss,
|
||
} from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
|
||
import {
|
||
isResourceCanvasHostOverlayOpen,
|
||
isResourceCanvasInteractionTarget,
|
||
isResourceCanvasPanTarget,
|
||
isResourceCanvasWheelOverlayTarget,
|
||
resolveResourceCanvasFloatingPanelDismissOpen,
|
||
resolveResourceCanvasFloatingPanelOpen,
|
||
resolveResourceCanvasFocusEscapeActive,
|
||
} 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 id="blank"></div>
|
||
<div class="game-resource-card" id="card">
|
||
<button class="game-resource-card-select" id="select"></button>
|
||
<button class="game-resource-card-media-control" id="play"></button>
|
||
<span role="button" id="corner">信息</span>
|
||
<video id="video"></video><input id="input" />
|
||
</div>
|
||
<div contenteditable="true" id="editor"></div>
|
||
<div class="game-resource-filter-panel"><span id="filter">筛选</span></div>
|
||
<div class="image-canvas-editor__floating-toolbar"><span id="toolbar">操作</span></div>
|
||
<div class="game-resource-book-scene-titlebar" id="title"></div>
|
||
`;
|
||
for (const id of ['blank', 'card', 'select']) {
|
||
expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(true);
|
||
}
|
||
for (const id of [
|
||
'play',
|
||
'corner',
|
||
'video',
|
||
'input',
|
||
'editor',
|
||
'filter',
|
||
'toolbar',
|
||
'title',
|
||
]) {
|
||
expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(
|
||
false,
|
||
);
|
||
}
|
||
expect(isResourceCanvasPanTarget(null)).toBe(false);
|
||
});
|
||
|
||
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 class="game-resource-info-panel"><div id="info-value">封面图</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(query('.game-resource-info-panel')),
|
||
).toBe(true);
|
||
expect(isResourceCanvasInteractionTarget(query('#info-value'))).toBe(true);
|
||
expect(isResourceCanvasInteractionTarget(null)).toBe(false);
|
||
|
||
document.body.innerHTML = '';
|
||
});
|
||
|
||
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('快速编辑 / 资源信息 / 生成动画任一浮层开着都算画布浮层打开', () => {
|
||
// 三种浮层共用一条开关判据:少算「生成动画」时,只有它开着的那一帧
|
||
// `resolveResourceCanvasFloatingPanelDismissOpen` 会判 false,点外部就收不掉面板。
|
||
expect(
|
||
resolveResourceCanvasFloatingPanelOpen({
|
||
isQuickEditPanelOpen: false,
|
||
isResourceInfoPanelOpen: false,
|
||
isCharacterAnimationPanelOpen: false,
|
||
}),
|
||
).toBe(false);
|
||
for (const panel of [
|
||
{ isQuickEditPanelOpen: true },
|
||
{ isResourceInfoPanelOpen: true },
|
||
{ isCharacterAnimationPanelOpen: true },
|
||
] as const) {
|
||
const isFloatingPanelOpen = resolveResourceCanvasFloatingPanelOpen({
|
||
isQuickEditPanelOpen: false,
|
||
isResourceInfoPanelOpen: false,
|
||
isCharacterAnimationPanelOpen: false,
|
||
...panel,
|
||
});
|
||
expect(isFloatingPanelOpen).toBe(true);
|
||
expect(
|
||
resolveResourceCanvasFloatingPanelDismissOpen({
|
||
isCanvasVisible: true,
|
||
isFloatingPanelOpen,
|
||
hostOverlay: {
|
||
isResourcePanelOpen: false,
|
||
isGenerationPanelOpen: false,
|
||
isClassificationPanelOpen: false,
|
||
isRenameDialogOpen: false,
|
||
isRecoveryPanelOpen: false,
|
||
},
|
||
}),
|
||
).toBe(true);
|
||
}
|
||
});
|
||
|
||
test('画布可见且画布浮层打开时才接管点外部关闭', () => {
|
||
const closed = {
|
||
isResourcePanelOpen: false,
|
||
isGenerationPanelOpen: false,
|
||
isClassificationPanelOpen: false,
|
||
isRenameDialogOpen: false,
|
||
isRecoveryPanelOpen: false,
|
||
};
|
||
const base = {
|
||
isCanvasVisible: true,
|
||
isFloatingPanelOpen: true,
|
||
hostOverlay: closed,
|
||
};
|
||
|
||
// 判据对浮层种类无差别:快速编辑与信息浮层共用这一条(宿主把两者合成一个布尔)。
|
||
expect(resolveResourceCanvasFloatingPanelDismissOpen(base)).toBe(true);
|
||
expect(
|
||
resolveResourceCanvasFloatingPanelDismissOpen({
|
||
...base,
|
||
isFloatingPanelOpen: false,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
resolveResourceCanvasFloatingPanelDismissOpen({
|
||
...base,
|
||
isCanvasVisible: false,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
resolveResourceCanvasFloatingPanelDismissOpen({
|
||
...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,
|
||
isFloatingPanelOpen: true,
|
||
hostOverlay: closed,
|
||
};
|
||
|
||
expect(resolveResourceCanvasFocusEscapeActive(base)).toBe(true);
|
||
// 选中工具条没有独立开关:只有选中、没有浮层时 Escape 同样要清选中。
|
||
expect(
|
||
resolveResourceCanvasFocusEscapeActive({
|
||
...base,
|
||
isFloatingPanelOpen: false,
|
||
hasSelectedResource: true,
|
||
}),
|
||
).toBe(true);
|
||
expect(
|
||
resolveResourceCanvasFocusEscapeActive({
|
||
...base,
|
||
isFloatingPanelOpen: false,
|
||
hasSelectedResource: false,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
resolveResourceCanvasFocusEscapeActive({
|
||
...base,
|
||
isCanvasVisible: false,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
resolveResourceCanvasFocusEscapeActive({
|
||
...base,
|
||
hostOverlay: { ...closed, isRenameDialogOpen: true },
|
||
}),
|
||
).toBe(false);
|
||
const documentPreviewOverlay = { ...closed, isDocumentPreviewOpen: true };
|
||
expect(
|
||
resolveResourceCanvasFocusEscapeActive({
|
||
...base,
|
||
hostOverlay: documentPreviewOverlay,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
resolveResourceCanvasFloatingPanelDismissOpen({
|
||
isCanvasVisible: true,
|
||
isFloatingPanelOpen: true,
|
||
hostOverlay: documentPreviewOverlay,
|
||
}),
|
||
).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-005 的验收视频里,这一下把整个快速编辑面板
|
||
* 连同画布选中一起收掉了。
|
||
*
|
||
* 这里用 React 的 `onClick`(应用里的真实形状)复现。
|
||
*/
|
||
function SelfUnmountingOverlayHarness({
|
||
onDismiss,
|
||
}: {
|
||
onDismiss: () => void;
|
||
}) {
|
||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||
const [restoreVisible, setRestoreVisible] = useState(true);
|
||
const [panelOpen, setPanelOpen] = useState(true);
|
||
|
||
useImageCanvasFloatingOptionDismiss({
|
||
isOpen: panelOpen,
|
||
boundaryRefs: [canvasRef],
|
||
onDismiss: () => {
|
||
onDismiss();
|
||
setPanelOpen(false);
|
||
},
|
||
});
|
||
|
||
return (
|
||
<div ref={canvasRef} data-testid="self-unmount-canvas">
|
||
{panelOpen ? (
|
||
<div role="dialog" aria-label="快速编辑浮层面板">
|
||
{restoreVisible ? (
|
||
<button type="button" onClick={() => setRestoreVisible(false)}>
|
||
恢复原文
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 同一种形状的**同步**版本:目标节点在自己的原生 click 监听里被 `remove()`。
|
||
*
|
||
* 边界判定必须在事件派发最早期完成——等冒泡到 `document` 再拿 `event.target` 现算,
|
||
* 节点已经脱离 DOM,`element.contains(target)` 恒为 false,「点浮层内部」就被误判成
|
||
* 「点外部」,面板被自己的按钮关掉。这条用例是 AGC-005 的回归判据:
|
||
* 冻结判定接上之前它是红的。
|
||
*/
|
||
function NativeSelfRemovingOverlayHarness({
|
||
onDismiss,
|
||
}: {
|
||
onDismiss: () => void;
|
||
}) {
|
||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||
const [panelOpen, setPanelOpen] = useState(true);
|
||
|
||
useImageCanvasFloatingOptionDismiss({
|
||
isOpen: panelOpen,
|
||
boundaryRefs: [canvasRef],
|
||
onDismiss: () => {
|
||
onDismiss();
|
||
setPanelOpen(false);
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
const button = buttonRef.current;
|
||
if (!button) {
|
||
return undefined;
|
||
}
|
||
const handleClick = () => {
|
||
button.remove();
|
||
};
|
||
button.addEventListener('click', handleClick);
|
||
return () => button.removeEventListener('click', handleClick);
|
||
}, [panelOpen]);
|
||
|
||
return (
|
||
<div ref={canvasRef} data-testid="native-self-unmount-canvas">
|
||
{panelOpen ? (
|
||
<div role="dialog" aria-label="快速编辑浮层面板">
|
||
<button ref={buttonRef} type="button">
|
||
恢复原文
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 宿主自己 portal 到 `body` 的浮层(`@` 资源选择器 / 候选菜单)由 `isInsideExtraOverlay`
|
||
* 登记;菜单项点完即卸载时同样不能被判成点外部——portal 语义必须与边界 DOM 一视同仁。
|
||
*/
|
||
function ExtraOverlaySelfRemovalHarness({
|
||
onDismiss,
|
||
}: {
|
||
onDismiss: () => void;
|
||
}) {
|
||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||
const itemRef = useRef<HTMLButtonElement | null>(null);
|
||
const [panelOpen, setPanelOpen] = useState(true);
|
||
|
||
useImageCanvasFloatingOptionDismiss({
|
||
isOpen: panelOpen,
|
||
boundaryRefs: [canvasRef],
|
||
onDismiss: () => {
|
||
onDismiss();
|
||
setPanelOpen(false);
|
||
},
|
||
isInsideExtraOverlay: (target) =>
|
||
target instanceof Element &&
|
||
target.closest('.resource-reference-picker') !== null,
|
||
});
|
||
|
||
useEffect(() => {
|
||
const item = itemRef.current;
|
||
if (!item) {
|
||
return undefined;
|
||
}
|
||
const handleClick = () => {
|
||
item.remove();
|
||
};
|
||
item.addEventListener('click', handleClick);
|
||
return () => item.removeEventListener('click', handleClick);
|
||
}, [panelOpen]);
|
||
|
||
if (!panelOpen) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div ref={canvasRef} data-testid="registered-overlay-canvas">
|
||
<div role="dialog" aria-label="快速编辑浮层面板" />
|
||
</div>
|
||
{createPortal(
|
||
<div className="resource-reference-picker">
|
||
<button ref={itemRef} type="button">
|
||
引用素材
|
||
</button>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
describe('点外部关闭的边界判据', () => {
|
||
test('点会自卸载的按钮不算点外部,面板不退出', () => {
|
||
const onDismiss = vi.fn();
|
||
render(<SelfUnmountingOverlayHarness onDismiss={onDismiss} />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '恢复原文' }));
|
||
|
||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||
expect(onDismiss).not.toHaveBeenCalled();
|
||
expect(
|
||
screen.getByRole('dialog', { name: '快速编辑浮层面板' }),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
test('按钮在自己这次点击里被同步卸载,也仍算点在浮层内部', () => {
|
||
const onDismiss = vi.fn();
|
||
render(<NativeSelfRemovingOverlayHarness onDismiss={onDismiss} />);
|
||
|
||
const button = screen.getByRole('button', { name: '恢复原文' });
|
||
fireEvent.click(button);
|
||
|
||
// 目标确实是在这次点击里卸载的——判定不能因此翻成「点外部」。
|
||
expect(button.isConnected).toBe(false);
|
||
expect(onDismiss).not.toHaveBeenCalled();
|
||
expect(
|
||
screen.getByRole('dialog', { name: '快速编辑浮层面板' }),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
test('登记的 portal 浮层项自己卸载也算内部,宿主面板不被误关', () => {
|
||
const onDismiss = vi.fn();
|
||
render(<ExtraOverlaySelfRemovalHarness onDismiss={onDismiss} />);
|
||
|
||
const item = screen.getByRole('button', { name: '引用素材' });
|
||
fireEvent.click(item);
|
||
|
||
expect(item.isConnected).toBe(false);
|
||
expect(onDismiss).not.toHaveBeenCalled();
|
||
expect(
|
||
screen.getByRole('dialog', { name: '快速编辑浮层面板' }),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
test('点边界外照旧立即关闭(对照)', () => {
|
||
const onDismiss = vi.fn();
|
||
render(<NativeSelfRemovingOverlayHarness onDismiss={onDismiss} />);
|
||
|
||
fireEvent.click(document.body);
|
||
|
||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||
expect(
|
||
screen.queryByRole('dialog', { name: '快速编辑浮层面板' }),
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('画布滚轮归属判据', () => {
|
||
/**
|
||
* 与「点外部关闭」同一份口径:portal 出去的东西 DOM 上不在边界里,但 React 的事件会沿
|
||
* React 树冒泡到画布宿主的 `onWheel`。滚轮这类事件必须按 DOM 判归属,否则用户在
|
||
* `@` 选择器列表上滚动时,背后的画布会跟着平移 / 缩放。
|
||
*/
|
||
test('portal 出去的浮层与已登记浮层归浮层,边界里的画布元素归画布', () => {
|
||
document.body.innerHTML = `
|
||
<div id="owner">
|
||
<div id="card-blank">画布空白</div>
|
||
<div class="image-canvas-editor__portal-menu"><span id="menu-item">共享弹出层选项</span></div>
|
||
<div class="resource-reference-picker"><span id="picker-item">选择器条目</span></div>
|
||
</div>
|
||
<div id="portal"><span id="portal-item">portal 到 body 的候选项</span></div>
|
||
`;
|
||
const ownerRef = { current: document.querySelector<HTMLElement>('#owner') };
|
||
const options = {
|
||
boundaryRefs: [ownerRef],
|
||
isInsideExtraOverlay: (target: EventTarget | null) =>
|
||
target instanceof Element &&
|
||
target.closest('.resource-reference-picker') !== null,
|
||
};
|
||
const at = (selector: string) =>
|
||
document.querySelector<HTMLElement>(selector);
|
||
|
||
// 画布自己的:DOM 在边界里,也不是浮层。
|
||
expect(isFloatingOverlayWheelEvent(at('#card-blank'), options)).toBe(false);
|
||
// portal 到 body 的浮层:DOM 根本不在边界里 ⇒ 归浮层。
|
||
expect(isFloatingOverlayWheelEvent(at('#portal-item'), options)).toBe(true);
|
||
// 边界里但落在共享弹出层上:与「点外部关闭」同口径 ⇒ 归浮层。
|
||
expect(isFloatingOverlayWheelEvent(at('#menu-item'), options)).toBe(true);
|
||
// 边界里但由宿主登记成浮层(`@` 选择器 / 候选菜单)⇒ 归浮层。
|
||
expect(isFloatingOverlayWheelEvent(at('#picker-item'), options)).toBe(true);
|
||
// 判不出归属(没有事件目标)时不抢:归浮层。
|
||
expect(isFloatingOverlayWheelEvent(null, options)).toBe(true);
|
||
|
||
document.body.innerHTML = '';
|
||
});
|
||
|
||
test('自带滚动区的画布浮层归浮层,资源卡与画布空白仍归画布', () => {
|
||
document.body.innerHTML = `
|
||
<div class="game-resource-book-manager">
|
||
<div class="game-resource-card"><span id="card">卡片</span></div>
|
||
<div id="blank">画布空白</div>
|
||
<div class="image-canvas-editor__generation-composer"><span id="prompt">快速编辑提示词</span></div>
|
||
<div class="game-resource-info-panel"><span id="info">信息浮层</span></div>
|
||
<div class="game-resource-filter-panel"><span id="filter">筛选面板</span></div>
|
||
</div>
|
||
`;
|
||
const at = (selector: string) =>
|
||
document.querySelector<HTMLElement>(selector);
|
||
|
||
expect(isResourceCanvasWheelOverlayTarget(at('#card'))).toBe(false);
|
||
expect(isResourceCanvasWheelOverlayTarget(at('#blank'))).toBe(false);
|
||
expect(isResourceCanvasWheelOverlayTarget(at('#prompt'))).toBe(true);
|
||
expect(isResourceCanvasWheelOverlayTarget(at('#info'))).toBe(true);
|
||
expect(isResourceCanvasWheelOverlayTarget(at('#filter'))).toBe(true);
|
||
expect(isResourceCanvasWheelOverlayTarget(null)).toBe(false);
|
||
|
||
document.body.innerHTML = '';
|
||
});
|
||
});
|
||
|
||
/** 与 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();
|
||
});
|
||
});
|