修复画布内层空白区域框选

框选命中判断支持 world 的真实后代元素

回归测试覆盖共享 CanvasWorld 内层 pointerdown 冒泡

同步记录共享画布框选命中排障经验
This commit is contained in:
2026-09-07 10:38:36 +08:00
parent be0ae6d716
commit 398ee64388
3 changed files with 39 additions and 9 deletions
@@ -4983,3 +4983,11 @@
- 原因:Tauri Windows bundler 执行自己的 `<tauri_tools_path>\NSIS\makensis.exe`,默认位于当前用户 `%LOCALAPPDATA%\tauri`,不使用 PATH 中预装的 `makensis.exe`Jenkins LocalSystem/systemprofile 的 AppData 可能无法启动该缓存程序。
- 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。
- 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。
## 共享画布框选需要识别 world 的真实后代命中
- 现象:共享 `CanvasWorld` 在 world 外层增加 `.genarrative-image-canvas__world-content` 后,点击或拖拽空白画布时事件 `target` 是内层 div;框选逻辑若只检查外层 world 本身,就会只清除焦点而不创建选框。
- 原因:viewport 上的 pointer 事件通过冒泡接收,`event.currentTarget` 是 viewport,空白区域的 `event.target` 可能是 world 的任意后代,不保证命中外层元素。
- 处理:框选命中判断使用 `Element.closest('.genarrative-image-canvas__world')`,并保留 viewport 自身命中路径;回归测试通过真实 `CanvasWorld` DOM 的 `pointerdown` 冒泡覆盖内层 world content。
- 验证:`src/components/image-editor/useImageCanvasStageInteractions.test.tsx` 覆盖内层 world content 命中,定向交互测试通过。
- 关联:`packages/image-canvas-react/src/useImageCanvasStageInteractions.ts``packages/image-canvas-react/src/CanvasWorld.tsx`
@@ -342,11 +342,12 @@ export function useImageCanvasStageInteractions({
event.preventDefault();
return;
}
const target = event.target as HTMLElement;
const target = event.target;
if (
effectiveTool === 'select' &&
(event.target === event.currentTarget ||
target.classList.contains('genarrative-image-canvas__world'))
(target instanceof Element &&
target.closest('.genarrative-image-canvas__world') !== null))
) {
event.preventDefault();
const rect = canvasViewportRef.current?.getBoundingClientRect();
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { CanvasWorld } from '@genarrative/image-canvas-react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import {
type PointerEvent as ReactPointerEvent,
@@ -150,7 +151,6 @@ function StageInteractionsHarness({
) => void;
}) {
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const worldRef = useRef<HTMLDivElement | null>(null);
const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select');
const [layers, setLayers] = useState<CanvasLayer[]>(() => [
createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }),
@@ -288,11 +288,9 @@ function StageInteractionsHarness({
onPointerMove={interaction.handlePointerMove}
onPointerUp={interaction.finishDrag}
>
<div
ref={worldRef}
className="genarrative-image-canvas__world"
data-testid="world"
/>
<CanvasWorld viewport={viewport} data-testid="world">
<span aria-hidden="true" />
</CanvasWorld>
{layers.map((layer) => (
<button
key={layer.id}
@@ -565,7 +563,8 @@ function StageInteractionsHarness({
pointerId: 3,
clientX: 20,
clientY: 20,
target: worldRef.current ?? undefined,
target:
document.querySelector('[data-testid="world"]') ?? undefined,
}),
);
}}
@@ -1240,6 +1239,28 @@ describe('useImageCanvasStageInteractions', () => {
);
});
it('starts a marquee when the real world content receives the pointer down', () => {
render(<StageInteractionsHarness />);
const worldContent = screen
.getByTestId('world')
.querySelector('.genarrative-image-canvas__world-content');
expect(worldContent).not.toBeNull();
const pointerDown = new Event('pointerdown', { bubbles: true });
Object.defineProperties(pointerDown, {
pointerId: { configurable: true, value: 30 },
clientX: { configurable: true, value: 20 },
clientY: { configurable: true, value: 20 },
button: { configurable: true, value: 0 },
buttons: { configurable: true, value: 1 },
});
act(() => {
worldContent?.dispatchEvent(pointerDown);
});
expect(screen.getByTestId('marquee').textContent).toBe('20,20');
});
it('shows snap guides when generation frames drag near layer alignment', () => {
render(<StageInteractionsHarness />);