Files
Genarrative/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts
T
suzmii fc67439802
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 树冒泡导致画布误吃滚轮」的根因与排查口径
2026-09-12 19:29:20 +08:00

155 lines
5.6 KiB
TypeScript

import { useEffect } from 'react';
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>;
onDismiss: () => void;
/** Escape 关闭后把焦点还给的触发按钮;不传则只关闭不移焦。 */
restoreFocusRef?: FloatingOptionBoundaryRef | null;
/**
* 宿主自己的「点这里不算外部」补充判据。
*
* 共享 hook 只认识 `image-canvas-editor__portal-menu` 这一类共享弹出层;宿主自己
* portal 到 body 的浮层(例如 `@` 资源选择器与它的候选菜单)用它在同一处登记,
* 否则点浮层里的候选项会被判成点外部、把宿主面板一起收掉。
* 不传时(网页端画布)判定与既有行为逐字一致。
*/
isInsideExtraOverlay?: (target: EventTarget | null) => boolean;
};
function isEventInsideBoundary(
target: EventTarget | null,
boundaryRefs: Array<FloatingOptionBoundaryRef | null | undefined>,
) {
if (!(target instanceof Node)) {
return false;
}
return boundaryRefs.some((boundaryRef) => {
const element = boundaryRef?.current;
return element ? element.contains(target) : false;
});
}
function isEventInsideFloatingMenu(target: EventTarget | null) {
return (
target instanceof Element &&
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,
onDismiss,
restoreFocusRef,
isInsideExtraOverlay,
}: UseImageCanvasFloatingOptionDismissOptions) {
useEffect(() => {
if (!isOpen || typeof document === 'undefined') {
return undefined;
}
const handleClick = (event: MouseEvent) => {
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
if (
isEventInsideFloatingOverlay(event.target, {
boundaryRefs,
isInsideExtraOverlay,
})
) {
return;
}
onDismiss();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
// 画布全局快捷键在 window 上监听同一个 Escape,且会把整个生成 dialog 关掉。
// 浮层打开时 Escape 归浮层所有:document 在冒泡路径上早于 window,这里截断,
// 否则用户只想收起参数浮层却会连整个面板一起丢失。
event.stopPropagation();
// 浮层 portal 到 body,关闭时焦点会掉到 body;只有焦点确实在浮层里才把它还给
// 触发按钮,避免抢走用户正在编辑的输入框。
const shouldRestoreFocus = isEventInsideFloatingMenu(
document.activeElement,
);
onDismiss();
if (shouldRestoreFocus) {
restoreFocusRef?.current?.focus();
}
};
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, [boundaryRefs, isInsideExtraOverlay, isOpen, onDismiss, restoreFocusRef]);
}