修复资源画布浮层里的滚轮被画布消费:@ 选择器列表滚动时画布跟着平移 / 缩放
Project CI / Repository checks (pull_request) Successful in 2m27s
Project CI / Frontend tests (pull_request) Successful in 3m3s
Project CI / Backend tests (pull_request) Successful in 6m33s
Project CI / Native shell tests (pull_request) Successful in 18m54s

- 共享浮层判据抽出 isEventInsideFloatingOverlay,并新增 isFloatingOverlayWheelEvent:关闭判定与滚轮归属共用同一份口径(DOM 不在边界里的 portal 浮层 + 已登记浮层)
- isEventInsideFloatingMenu 返回值收紧为布尔,避免判据把命中的元素当真值往下传
- 资源画布唯一滚轮入口 handleResourceBookWheel 加统一守卫:浮层里的滚轮一律不消费(不 preventDefault、不动视口),不再逐浮层加 stopPropagation
- resourceCanvasFocusModel 增加 RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR 与 isResourceCanvasWheelOverlayTarget,登记留在画布 DOM 里自带滚动区的浮层(快速编辑 / 信息 / 筛选)
- projectResourceLiveIntegration 新增用例:在选择器列表上派发真实 wheel → 浮层自己收到该事件、画布 data-resource-viewport 不变;对照组场景根派发 wheel → 视口照旧变化
- resourceCanvasFloatingDismiss 新增判据单测:portal 浮层 / 共享弹出层 / 已登记浮层三种来源归浮层,边界里的卡片与空白仍归画布,判不出归属不抢
- pitfalls.md 记录「portal 事件沿 React 树冒泡导致画布误吃滚轮」的根因与排查口径
This commit is contained in:
2026-09-12 19:29:20 +08:00
parent e4c2f2466f
commit fc67439802
6 changed files with 298 additions and 7 deletions
@@ -38,6 +38,37 @@ export function isResourceCanvasInteractionTarget(
return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR));
}
/**
* 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。
*
* 词表与 `RESOURCE_CANVAS_INTERACTION_SELECTOR` 的浮层成员同一套类名,只去掉按钮 / 输入 /
* 资源卡这类本身不该吞滚轮的交互控件(在卡片上滚轮照旧平移 / 缩放画布):
* - `.image-canvas-editor__generation-composer`:快速编辑 / 角色动画浮层(自带提示词滚动区);
* - `.game-resource-info-panel`:资源信息浮层(`overflow: auto`);
* - `.game-resource-filter-panel`:筛选面板(卡片区 `overflow-y: auto`)。
*/
export const RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR = [
'.image-canvas-editor__generation-composer',
'.game-resource-info-panel',
'.game-resource-filter-panel',
].join(', ');
/**
* 这一次滚轮是不是落在「自己吃滚轮的画布浮层」里。
*
* portal 到 `document.body` 的浮层(`@` 资源选择器与候选菜单、共享模型 / 比例弹出层…)由
* `isResourceReferenceOverlayTarget` 与共享 hook 的 portal 弹层判据负责,这里只管留在画布
* DOM 里的浮层。
*/
export function isResourceCanvasWheelOverlayTarget(
target: EventTarget | null,
): boolean {
return (
target instanceof Element &&
target.closest(RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR) !== null
);
}
/**
* 画布浮层是否可以被清焦点顺手关掉。
*
@@ -88,7 +88,10 @@ import {
import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
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 {
isFloatingOverlayWheelEvent,
useImageCanvasFloatingOptionDismiss,
} from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel';
import {
LocalGamePreviewFrame,
@@ -115,6 +118,7 @@ import {
import {
canDismissResourceCanvasQuickEdit,
isResourceCanvasInteractionTarget,
isResourceCanvasWheelOverlayTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFocusEscapeActive,
} from '../../features/resource-canvas/resourceCanvasFocusModel';
@@ -1120,6 +1124,23 @@ function ResourceBookTitleBar({
);
}
/**
*
* - `@` portal `document.body`
* - DOM / /
* `isResourceCanvasWheelOverlayTarget`
*
* preventDefault
*/
function isResourceCanvasFloatingOverlayWheelTarget(
target: EventTarget | null,
) {
return (
isResourceReferenceOverlayTarget(target) ||
isResourceCanvasWheelOverlayTarget(target)
);
}
function ResourceBookScene({
plan,
resourceBookState,
@@ -4406,11 +4427,28 @@ export default function ProjectDevelopmentView({
const handleResourceBookWheel = useCallback(
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
resourceBookTransitionControllerRef.current.settle();
const sceneElement = resourceBookManagerRef.current;
if (!sceneElement) {
return;
}
/*
React portal 沿 **React ** React portal
`document.body` `@` `onWheel`
DOM
`isFloatingOverlayWheelEvent` = +
/
*/
if (
isFloatingOverlayWheelEvent(event.target, {
boundaryRefs: [resourceBookManagerRef],
isInsideExtraOverlay: isResourceCanvasFloatingOverlayWheelTarget,
})
) {
return;
}
resourceBookTransitionControllerRef.current.settle();
event.preventDefault();
const rect = sceneElement.getBoundingClientRect();
const screenPoint = {
@@ -893,6 +893,93 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[0]?.prompt).toBe(expectedText);
});
/**
* 用户报的原始现象:在「快速编辑 → 插入素材引用」开出的选择器列表上滚鼠标滚轮,
* 滚的不是列表,而是背后的资源画布(画布跟着缩放 / 平移)。
*
* 链路:选择器 portal 到 `document.body`,而 React 的 portal 事件沿 **React 树** 冒泡
* React 把委托监听挂在 portal 容器上),所以它的 wheel 照样走到画布场景根的
* `onWheel`;修复前那一下会被画布消费掉。这里用真实事件序列钉住「浮层里的滚轮归浮层、
* 画布视口一格不动」,同时用对照用例钉住「画布本体的滚轮照旧」。
*/
it('在选择素材浮层里滚轮:浮层自己收到、画布视口不动,画布本体滚轮照旧', async () => {
installTauri();
render(<DerivedWorkbench includeArt />);
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
fireEvent.click(await findResourceSelectButton('source-art.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
const panel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
fireEvent.click(
within(panel).getByRole('button', { name: '插入素材引用' }),
);
const picker = await screen.findByRole('dialog', { name: '选择素材' });
// 前提自检:选择器 DOM 上确实不在资源画本里(这正是 React 事件仍会冒泡到画布的原因)。
const manager = document.querySelector('.game-resource-book-manager');
expect(manager?.contains(picker)).toBe(false);
expect(picker.parentElement).toBe(document.body);
const list = picker.querySelector('.resource-reference-picker-list');
if (!list) throw new Error('missing picker list');
const overlayWheelCalls = vi.fn();
list.addEventListener('wheel', overlayWheelCalls);
const readViewport = () =>
document
.querySelector('[data-resource-viewport]')
?.getAttribute('data-resource-viewport');
const readSceneWorldTransform = () =>
document
.querySelector('.game-resource-book-scene-world')
?.getAttribute('style');
const viewportBefore = readViewport();
const sceneWorldBefore = readSceneWorldTransform();
expect(viewportBefore).toBeTruthy();
// 真实事件序列:从浮层内部元素派发滚轮(等同用户在选择器列表上滚)。
const overlayWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 240,
clientX: 80,
clientY: 60,
});
act(() => {
list.dispatchEvent(overlayWheel);
});
// 浮层自己收到该事件、且没被画布消费(列表按原生行为滚动)。
expect(overlayWheelCalls).toHaveBeenCalledTimes(1);
expect(overlayWheel.defaultPrevented).toBe(false);
// 画布视口一格不动。
expect(readViewport()).toBe(viewportBefore);
expect(readSceneWorldTransform()).toBe(sceneWorldBefore);
list.removeEventListener('wheel', overlayWheelCalls);
// 对照用例:画布本体(场景根)上的滚轮必须照旧平移视口——修复没把画布交互一起关掉。
const sceneRoot = document.querySelector('.game-resource-book-scene');
if (!sceneRoot) throw new Error('missing scene root');
const canvasWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
clientX: 90,
clientY: 70,
});
act(() => {
sceneRoot.dispatchEvent(canvasWheel);
});
expect(canvasWheel.defaultPrevented).toBe(true);
expect(readViewport()).not.toBe(viewportBefore);
});
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
@@ -6,11 +6,15 @@ 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 {
isFloatingOverlayWheelEvent,
useImageCanvasFloatingOptionDismiss,
} from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import {
canDismissResourceCanvasQuickEdit,
isResourceCanvasHostOverlayOpen,
isResourceCanvasInteractionTarget,
isResourceCanvasWheelOverlayTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFocusEscapeActive,
} from '../src/features/resource-canvas/resourceCanvasFocusModel';
@@ -315,6 +319,69 @@ describe('资源画布浮层关闭时机', () => {
});
});
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,
@@ -2,6 +2,15 @@
> 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。
## 2026-09-12 画布滚轮要按 DOM 归属判定,portal 出去的浮层不能把滚轮让给画布
- **现象**:资源卡「快速编辑」里用 `@` 开出「选择素材」浮层后,在选择器列表上滚鼠标滚轮,列表自己在滚,背后的资源画布也一起平移 / 缩放(用户口语:「滚轮还是回滚到画布上」)。
- **原因**:选择器与输入区候选菜单 `createPortal(..., document.body)`,DOM 上不在画布管理区里;但 React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器上),所以它们的 `wheel` 照样走到画布场景根的 `onWheel`,被当成画布手势消费。React 的 `wheel` 委托监听是 passive 的:`preventDefault()` 是空操作(只报 warning),真正出问题的是视口状态被改写——所以「事件没被 preventDefault」不能作为「画布没吃这一下」的判据。
- **处理**:滚轮归属与「点外部关闭」共用同一份浮层口径(`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts``isEventInsideFloatingOverlay` + `isFloatingOverlayWheelEvent`)——DOM 不在宿主边界里的(portal 出去的一律算浮层)与已登记为浮层内部的都归浮层。资源画布只保留 `handleResourceBookWheel` 一处守卫,留在画布 DOM 里自带滚动区的浮层(快速编辑 / 信息 / 筛选,见 `RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR`)按同一入口登记,不要再逐浮层加 `stopPropagation`。判据判不出归属时(没有元素目标)不抢滚轮。
- **排查顺序**:先确认浮层是不是 portal 出去的;是的话不要先怀疑 CSS `overflow``overscroll-behavior` 或事件被 `preventDefault`,直接查画布宿主上的 `onWheel` / `onPointerDown` 有没有做 DOM 归属判断。
- **验证**`npm run test -- apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx`(判据单测,含 portal / 共享弹出层 / 已登记浮层三种来源与「判不出归属不抢」)与 `npm run test -- apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`(真实事件序列:选择器列表里派发 `wheel` → 画布 `data-resource-viewport` 不变、浮层自己收到该事件;对照组:场景根上派发 `wheel` → 视口照旧变化)。
- **关联**`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts``apps/ai-game-creator-shell/src/view/project-development/index.tsx``handleResourceBookWheel`)、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts`
## 2026-09-12 策划项目重开前必须恢复运行模式
- 工作台不能只依赖创建时的内存 `startMode`:重开时丢失该值会挂载游戏资源画布,而对话恢复后又进入策划状态,造成左右区域不一致。
@@ -4,6 +4,12 @@ type FloatingOptionBoundaryRef = {
readonly current: HTMLElement | null;
};
/** 浮层边界登记:谁算「浮层内部」,关闭判定与滚轮归属共用同一份。 */
type FloatingOverlayBoundaryOptions = {
boundaryRefs?: Array<FloatingOptionBoundaryRef | null | undefined>;
isInsideExtraOverlay?: (target: EventTarget | null) => boolean;
};
type UseImageCanvasFloatingOptionDismissOptions = {
isOpen: boolean;
boundaryRefs: Array<FloatingOptionBoundaryRef | null | undefined>;
@@ -38,10 +44,62 @@ function isEventInsideBoundary(
function isEventInsideFloatingMenu(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest('.image-canvas-editor__portal-menu')
target.closest('.image-canvas-editor__portal-menu') !== null
);
}
/**
* 事件是否落在「已登记为浮层」的那几块 DOM 里。
*
* 这些浮层 portal 到 `document.body`(共享弹出层)或由宿主额外登记(`isInsideExtraOverlay`),
* 关闭判定与滚轮归属共用这一份口径,避免两处各写一套白名单。
*/
function isEventInsideRegisteredOverlay(
target: EventTarget | null,
isInsideExtraOverlay?: (target: EventTarget | null) => boolean,
) {
return (
isEventInsideFloatingMenu(target) || isInsideExtraOverlay?.(target) === true
);
}
/**
* 「点外部关闭」的边界判据:DOM 在边界里(含 portal 出去的浮层登记)就算内部。
*/
export function isEventInsideFloatingOverlay(
target: EventTarget | null,
{ boundaryRefs = [], isInsideExtraOverlay }: FloatingOverlayBoundaryOptions,
) {
return (
isEventInsideBoundary(target, boundaryRefs) ||
isEventInsideRegisteredOverlay(target, isInsideExtraOverlay)
);
}
/**
* 「这一下滚轮归浮层还是归画布」的判据,与上面的关闭判定同源。
*
* React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器 `document.body`
* 上),所以 portal 出去的浮层里的滚轮照样会走到宿主的 `onWheel`。DOM 上根本不在宿主里的
* 那一下,只可能是浮层派出来的:必须原样放过,否则用户在 `@` 选择器列表上滚轮时,列表
* 在滚、背后的画布也一起平移 / 缩放。
*
* 两条判据:
* 1. DOM 不在边界里 —— portal 出去的浮层(`@` 选择器、候选菜单、共享弹出层……),一律归浮层;
* 2. DOM 仍在边界里、但已登记为浮层内部的(`isInsideExtraOverlay` 与共享弹出层),同样归浮层。
*
* 返回 `true` 表示这次滚轮归浮层,宿主不得消费(不 preventDefault、不动视口)。
*/
export function isFloatingOverlayWheelEvent(
target: EventTarget | null,
{ boundaryRefs = [], isInsideExtraOverlay }: FloatingOverlayBoundaryOptions,
) {
if (!isEventInsideBoundary(target, boundaryRefs)) {
return true;
}
return isEventInsideRegisteredOverlay(target, isInsideExtraOverlay);
}
export function useImageCanvasFloatingOptionDismiss({
isOpen,
boundaryRefs,
@@ -57,9 +115,10 @@ export function useImageCanvasFloatingOptionDismiss({
const handleClick = (event: MouseEvent) => {
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
if (
isEventInsideBoundary(event.target, boundaryRefs) ||
isEventInsideFloatingMenu(event.target) ||
isInsideExtraOverlay?.(event.target) === true
isEventInsideFloatingOverlay(event.target, {
boundaryRefs,
isInsideExtraOverlay,
})
) {
return;
}