130 lines
3.7 KiB
TypeScript
130 lines
3.7 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import type { MenuNode } from '@/lib/api/types/menu';
|
|
|
|
const HOVER_LEAVE_MS = 280;
|
|
|
|
type FlyoutState = {
|
|
flyoutRoot: MenuNode | null;
|
|
l1AnchorRect: DOMRect | null;
|
|
openFlyout: (node: MenuNode, anchorEl: HTMLDivElement) => void;
|
|
scheduleCloseFlyout: () => void;
|
|
clearCloseTimer: () => void;
|
|
closeFlyoutNow: () => void;
|
|
toggleFlyoutClick: (node: MenuNode, wrapper: HTMLDivElement | null) => void;
|
|
l1AnchorElRef: React.RefObject<HTMLDivElement | null>;
|
|
railScrollRef: React.RefObject<HTMLDivElement | null>;
|
|
};
|
|
|
|
/** 从 ClassicCollapsedSidebar 提取的 flyout 状态管理逻辑 */
|
|
export function useFlyoutState(pathname: string): FlyoutState {
|
|
const [flyoutRoot, setFlyoutRoot] = useState<MenuNode | null>(null);
|
|
const [l1AnchorRect, setL1AnchorRect] = useState<DOMRect | null>(null);
|
|
const l1AnchorElRef = useRef<HTMLDivElement | null>(null);
|
|
const railScrollRef = useRef<HTMLDivElement | null>(null);
|
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const clearCloseTimer = useCallback(() => {
|
|
if (closeTimerRef.current) {
|
|
clearTimeout(closeTimerRef.current);
|
|
closeTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const closeFlyout = useCallback(() => {
|
|
setFlyoutRoot(null);
|
|
setL1AnchorRect(null);
|
|
l1AnchorElRef.current = null;
|
|
}, []);
|
|
|
|
const scheduleCloseFlyout = useCallback(() => {
|
|
clearCloseTimer();
|
|
closeTimerRef.current = setTimeout(() => {
|
|
closeFlyout();
|
|
closeTimerRef.current = null;
|
|
}, HOVER_LEAVE_MS);
|
|
}, [clearCloseTimer, closeFlyout]);
|
|
|
|
const closeFlyoutNow = useCallback(() => {
|
|
clearCloseTimer();
|
|
closeFlyout();
|
|
}, [clearCloseTimer, closeFlyout]);
|
|
|
|
const syncL1Anchor = useCallback(() => {
|
|
const el = l1AnchorElRef.current;
|
|
if (el) setL1AnchorRect(el.getBoundingClientRect());
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (flyoutRoot) syncL1Anchor();
|
|
}, [flyoutRoot, syncL1Anchor]);
|
|
|
|
useEffect(() => {
|
|
if (!flyoutRoot) return;
|
|
const onResize = () => syncL1Anchor();
|
|
window.addEventListener('resize', onResize);
|
|
const rs = railScrollRef.current;
|
|
rs?.addEventListener('scroll', onResize, { passive: true });
|
|
return () => {
|
|
window.removeEventListener('resize', onResize);
|
|
rs?.removeEventListener('scroll', onResize);
|
|
};
|
|
}, [flyoutRoot, syncL1Anchor]);
|
|
|
|
useEffect(() => {
|
|
closeFlyout();
|
|
}, [pathname, closeFlyout]);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') closeFlyout();
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [closeFlyout]);
|
|
|
|
useEffect(() => clearCloseTimer, [clearCloseTimer]);
|
|
|
|
const openFlyout = useCallback(
|
|
(node: MenuNode, anchorEl: HTMLDivElement) => {
|
|
clearCloseTimer();
|
|
l1AnchorElRef.current = anchorEl;
|
|
setL1AnchorRect(anchorEl.getBoundingClientRect());
|
|
setFlyoutRoot(node);
|
|
},
|
|
[clearCloseTimer],
|
|
);
|
|
|
|
const toggleFlyoutClick = useCallback(
|
|
(node: MenuNode, wrapper: HTMLDivElement | null) => {
|
|
setFlyoutRoot((prev) => {
|
|
if (prev?.id === node.id) {
|
|
setL1AnchorRect(null);
|
|
l1AnchorElRef.current = null;
|
|
return null;
|
|
}
|
|
if (wrapper) {
|
|
l1AnchorElRef.current = wrapper;
|
|
setL1AnchorRect(wrapper.getBoundingClientRect());
|
|
return node;
|
|
}
|
|
return prev;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
return {
|
|
flyoutRoot,
|
|
l1AnchorRect,
|
|
openFlyout,
|
|
scheduleCloseFlyout,
|
|
clearCloseTimer,
|
|
closeFlyoutNow,
|
|
toggleFlyoutClick,
|
|
l1AnchorElRef,
|
|
railScrollRef,
|
|
};
|
|
}
|