diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index 6096de61c..1c37bd3eb 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -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 + ); +} + /** * 画布浮层是否可以被清焦点顺手关掉。 * diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index a63058a09..884c20fb2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -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 | 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 = { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 58c5ebf77..d02c743b5 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -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(); + + 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(); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index 6313488ab..e4bd72b11 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -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 = ` +
+
画布空白
+
共享弹出层选项
+
选择器条目
+
+
portal 到 body 的候选项
+ `; + const ownerRef = { current: document.querySelector('#owner') }; + const options = { + boundaryRefs: [ownerRef], + isInsideExtraOverlay: (target: EventTarget | null) => + target instanceof Element && + target.closest('.resource-reference-picker') !== null, + }; + const at = (selector: string) => + document.querySelector(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 = ` +
+
卡片
+
画布空白
+
快速编辑提示词
+
信息浮层
+
筛选面板
+
+ `; + const at = (selector: string) => + document.querySelector(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, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 39626df0b..b55af4ce4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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`:重开时丢失该值会挂载游戏资源画布,而对话恢复后又进入策划状态,造成左右区域不一致。 diff --git a/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts b/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts index 6e9762b5e..25e13d409 100644 --- a/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts +++ b/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts @@ -4,6 +4,12 @@ type FloatingOptionBoundaryRef = { readonly current: HTMLElement | null; }; +/** 浮层边界登记:谁算「浮层内部」,关闭判定与滚轮归属共用同一份。 */ +type FloatingOverlayBoundaryOptions = { + boundaryRefs?: Array; + isInsideExtraOverlay?: (target: EventTarget | null) => boolean; +}; + type UseImageCanvasFloatingOptionDismissOptions = { isOpen: boolean; boundaryRefs: Array; @@ -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; }