修复资源画布浮层里的滚轮被画布消费:@ 选择器列表滚动时画布跟着平移 / 缩放
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
@@ -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;
}