import { useEffect, useRef } from 'react'; type FloatingOptionBoundaryRef = { readonly current: HTMLElement | null; }; /** 浮层边界登记:谁算「浮层内部」,关闭判定与滚轮归属共用同一份。 */ type FloatingOverlayBoundaryOptions = { boundaryRefs?: Array; isInsideExtraOverlay?: (target: EventTarget | null) => boolean; }; type UseImageCanvasFloatingOptionDismissOptions = { isOpen: boolean; boundaryRefs: Array; 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, ) { 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) { /** * 本次点击「算不算点在浮层里」的冻结判定。 * * 判定必须在事件派发的最早期做,不能在冒泡到 `document` 时才拿 `event.target` 现算: * 浮层里的按钮可能在**这一次点击**里把自己卸载掉(AGC 画布验收 AGC-005:快速编辑的 * 「恢复原文」被点后原文快照清空、按钮不再渲染,验收视频里表现为整个编辑面板连同选中 * 一起被收掉)。目标节点一旦脱离 DOM,`element.contains(target)` 一律为 false, * 「点浮层内部」就被误判成「点外部」,面板被自己的按钮关掉。 * * 捕获阶段挂在 `document` 上是最早的一站(React 的委托监听在 root 容器上,位置更靠内), * 此刻 DOM 还是用户看到的那一份;判定口径本身不变,仍是 * {@link isEventInsideFloatingOverlay}(边界 DOM + portal 到 body 的浮层登记)。 * 真正触发关闭仍然留在冒泡阶段,宿主其它处理器看到的事件顺序与既有行为逐字一致。 */ const clickVerdictRef = useRef<{ event: MouseEvent; inside: boolean; } | null>(null); useEffect(() => { if (!isOpen || typeof document === 'undefined') { return undefined; } const resolveInside = (event: MouseEvent) => isEventInsideFloatingOverlay(event.target, { boundaryRefs, isInsideExtraOverlay, }); const handleClickCapture = (event: MouseEvent) => { clickVerdictRef.current = { event, inside: resolveInside(event) }; }; const handleClick = (event: MouseEvent) => { // 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。 const frozen = clickVerdictRef.current; clickVerdictRef.current = null; // 捕获阶段没跑到(例如事件不是这条派发路径派出来的)时退回现算,行为与改动前一致。 const isInside = frozen && frozen.event === event ? frozen.inside : resolveInside(event); if (isInside) { 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', handleClickCapture, true); document.addEventListener('click', handleClick); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('click', handleClickCapture, true); document.removeEventListener('click', handleClick); document.removeEventListener('keydown', handleKeyDown); }; }, [boundaryRefs, isInsideExtraOverlay, isOpen, onDismiss, restoreFocusRef]); }