fc67439802
- 共享浮层判据抽出 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 树冒泡导致画布误吃滚轮」的根因与排查口径
155 lines
5.6 KiB
TypeScript
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]);
|
|
}
|