🐛 fix(mobile): render topic menus and rename popovers inside active overlay container (#12477)

This commit is contained in:
Sun13138
2026-03-22 01:15:28 +08:00
committed by GitHub
parent 81bd6dc732
commit f9166133a7
10 changed files with 209 additions and 95 deletions

View File

@@ -6,6 +6,8 @@ import { type InputRef, type PopoverProps } from 'antd';
import { type KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useOverlayPopoverPortalProps } from '@/features/NavPanel/OverlayContainer';
function FocusableInput(props: InputProps) {
const ref = useRef<InputRef>(null);
useEffect(() => {
@@ -51,6 +53,7 @@ const InlineRename = memo<InlineRenameProps>(
({ open, title, onOpenChange, onSave, onCancel, placement = 'bottomLeft', width = 320 }) => {
const [newTitle, setNewTitle] = useState(title);
const savedRef = useRef(false);
const popoverPortalProps = useOverlayPopoverPortalProps();
// Reset state when opening
useEffect(() => {
@@ -89,6 +92,7 @@ const InlineRename = memo<InlineRenameProps>(
<Popover
open={open}
placement={placement}
portalProps={popoverPortalProps}
trigger="click"
content={
<FocusableInput

View File

@@ -0,0 +1,45 @@
import type { DropdownMenuProps, PopoverProps } from '@lobehub/ui';
import { createContext, useContext, useMemo } from 'react';
import { useServerConfigStore } from '@/store/serverConfig';
export const OverlayContainerContext = createContext<HTMLDivElement | null>(null);
interface OverlayPopoverPortalProps extends NonNullable<PopoverProps['portalProps']> {
container?: HTMLElement | null;
}
export const useOverlayContainer = () => {
return useContext(OverlayContainerContext);
};
const useMobileOverlayContainer = () => {
const mobile = useServerConfigStore((s) => s.isMobile);
const container = useOverlayContainer();
return useMemo(() => {
if (!mobile || !container) return undefined;
return container;
}, [container, mobile]);
};
export const useOverlayDropdownPortalProps = (): DropdownMenuProps['portalProps'] => {
const container = useMobileOverlayContainer();
return useMemo(() => {
if (!container) return undefined;
return { container };
}, [container]);
};
export const useOverlayPopoverPortalProps = (): OverlayPopoverPortalProps | undefined => {
const container = useMobileOverlayContainer();
return useMemo(() => {
if (!container) return undefined;
return { container };
}, [container]);
};

View File

@@ -4,13 +4,14 @@ import { ActionIcon, Flexbox, Text } from '@lobehub/ui';
import { Drawer } from 'antd';
import { cssVar } from 'antd-style';
import { XIcon } from 'lucide-react';
import { type ReactNode } from 'react';
import { memo, Suspense } from 'react';
import type { ReactNode, Ref } from 'react';
import { cloneElement, isValidElement, memo, Suspense, useCallback, useState } from 'react';
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import { NAV_PANEL_RIGHT_DRAWER_ID } from './';
import SkeletonList from './components/SkeletonList';
import { OverlayContainerContext } from './OverlayContainer';
import SideBarHeaderLayout from './SideBarHeaderLayout';
interface SideBarDrawerProps {
@@ -22,83 +23,119 @@ interface SideBarDrawerProps {
title?: ReactNode;
}
interface DrawerRenderNodeProps {
containerRef?: Ref<HTMLDivElement>;
}
const setRef = <T,>(ref: Ref<T> | undefined, value: T | null) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(value);
return;
}
(ref as { current: T | null }).current = value;
};
const SideBarDrawer = memo<SideBarDrawerProps>(
({ subHeader, open, onClose, children, title, action }) => {
const size = 280;
const [overlayContainer, setOverlayContainer] = useState<HTMLDivElement | null>(null);
const renderDrawerContent = useCallback((node: ReactNode) => {
if (!isValidElement<DrawerRenderNodeProps>(node)) return node;
const originalContainerRef = node.props.containerRef;
// Intentionally hook rc-drawer's section ref so dropdown portals stay inside the real drawer content.
// eslint-disable-next-line @eslint-react/no-clone-element
return cloneElement(node, {
containerRef: (instance: HTMLDivElement | null) => {
setOverlayContainer((current) => (current === instance ? current : instance));
setRef(originalContainerRef, instance);
},
});
}, []);
return (
<Drawer
destroyOnHidden
closable={false}
getContainer={() => document.querySelector(`#${NAV_PANEL_RIGHT_DRAWER_ID}`)!}
mask={false}
open={open}
placement="left"
size={size}
rootStyle={{
bottom: 0,
overflow: 'hidden',
position: 'absolute',
top: 0,
width: `${size}px`,
}}
styles={{
body: {
background: cssVar.colorBgLayout,
padding: 0,
},
header: {
background: cssVar.colorBgLayout,
borderBottom: 'none',
padding: 0,
},
wrapper: {
borderLeft: `1px solid ${cssVar.colorBorderSecondary}`,
borderRight: `1px solid ${cssVar.colorBorderSecondary}`,
boxShadow: `4px 0 8px -2px rgba(0,0,0,.04)`,
zIndex: 0,
},
}}
title={
<>
<SideBarHeaderLayout
showBack={false}
showTogglePanelButton={false}
left={
typeof title === 'string' ? (
<Text
ellipsis
fontSize={14}
style={{ fontWeight: 600, paddingLeft: 8 }}
weight={400}
>
{title}
</Text>
) : (
title
)
}
right={
<>
{action}
<ActionIcon icon={XIcon} size={DESKTOP_HEADER_ICON_SIZE} onClick={onClose} />
</>
}
/>
{subHeader}
</>
}
onClose={onClose}
>
<Suspense
fallback={
<Flexbox gap={1} paddingBlock={1} paddingInline={4}>
<SkeletonList rows={3} />
</Flexbox>
<OverlayContainerContext value={overlayContainer}>
<Drawer
destroyOnHidden
closable={false}
drawerRender={renderDrawerContent}
getContainer={() => document.querySelector(`#${NAV_PANEL_RIGHT_DRAWER_ID}`)!}
mask={false}
open={open}
placement="left"
size={size}
rootStyle={{
bottom: 0,
overflow: 'hidden',
position: 'absolute',
top: 0,
width: `${size}px`,
}}
styles={{
body: {
background: cssVar.colorBgLayout,
padding: 0,
},
header: {
background: cssVar.colorBgLayout,
borderBottom: 'none',
padding: 0,
},
wrapper: {
borderLeft: `1px solid ${cssVar.colorBorderSecondary}`,
borderRight: `1px solid ${cssVar.colorBorderSecondary}`,
boxShadow: `4px 0 8px -2px rgba(0,0,0,.04)`,
zIndex: 0,
},
}}
title={
<>
<SideBarHeaderLayout
showBack={false}
showTogglePanelButton={false}
left={
typeof title === 'string' ? (
<Text
ellipsis
fontSize={14}
style={{ fontWeight: 600, paddingLeft: 8 }}
weight={400}
>
{title}
</Text>
) : (
title
)
}
right={
<>
{action}
<ActionIcon icon={XIcon} size={DESKTOP_HEADER_ICON_SIZE} onClick={onClose} />
</>
}
/>
{subHeader}
</>
}
onClose={onClose}
>
{children}
</Suspense>
</Drawer>
<Suspense
fallback={
<Flexbox gap={1} paddingBlock={1} paddingInline={4}>
<SkeletonList rows={3} />
</Flexbox>
}
>
{children}
</Suspense>
</Drawer>
</OverlayContainerContext>
);
},
);

View File

@@ -1,15 +1,19 @@
import { type DropdownItem } from '@lobehub/ui';
import type { DropdownItem } from '@lobehub/ui';
import { ActionIcon, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
import { useOverlayDropdownPortalProps } from '@/features/NavPanel/OverlayContainer';
interface ActionProps {
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
const dropdownPortalProps = useOverlayDropdownPortalProps();
return (
<DropdownMenu items={dropdownMenu}>
<DropdownMenu items={dropdownMenu} portalProps={dropdownPortalProps}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</DropdownMenu>
);

View File

@@ -1,15 +1,19 @@
import { type DropdownItem } from '@lobehub/ui';
import type { DropdownItem } from '@lobehub/ui';
import { ActionIcon, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
import { useOverlayDropdownPortalProps } from '@/features/NavPanel/OverlayContainer';
interface ActionProps {
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
const dropdownPortalProps = useOverlayDropdownPortalProps();
return (
<DropdownMenu items={dropdownMenu}>
<DropdownMenu items={dropdownMenu} portalProps={dropdownPortalProps}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</DropdownMenu>
);

View File

@@ -1,15 +1,19 @@
import { type DropdownItem } from '@lobehub/ui';
import type { DropdownItem } from '@lobehub/ui';
import { ActionIcon, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
import { useOverlayDropdownPortalProps } from '@/features/NavPanel/OverlayContainer';
interface ActionProps {
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
const dropdownPortalProps = useOverlayDropdownPortalProps();
return (
<DropdownMenu items={dropdownMenu}>
<DropdownMenu items={dropdownMenu} portalProps={dropdownPortalProps}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</DropdownMenu>
);

View File

@@ -1,6 +1,7 @@
import { Input, Popover, stopPropagation } from '@lobehub/ui';
import { memo, useCallback, useState } from 'react';
import { useOverlayPopoverPortalProps } from '@/features/NavPanel/OverlayContainer';
import { useChatStore } from '@/store/chat';
interface EditingProps {
@@ -15,6 +16,7 @@ const Editing = memo<EditingProps>(({ id, title, toggleEditing }) => {
s.topicRenamingId === id,
s.updateTopicTitle,
]);
const popoverPortalProps = useOverlayPopoverPortalProps();
const handleUpdate = useCallback(async () => {
if (newTitle && title !== newTitle) {
@@ -48,6 +50,7 @@ const Editing = memo<EditingProps>(({ id, title, toggleEditing }) => {
<Popover
open={editing}
placement="bottomLeft"
portalProps={popoverPortalProps}
trigger="click"
content={
<Input

View File

@@ -1,15 +1,19 @@
import { type DropdownItem } from '@lobehub/ui';
import type { DropdownItem } from '@lobehub/ui';
import { ActionIcon, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
import { useOverlayDropdownPortalProps } from '@/features/NavPanel/OverlayContainer';
interface ActionProps {
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
const dropdownPortalProps = useOverlayDropdownPortalProps();
return (
<DropdownMenu items={dropdownMenu}>
<DropdownMenu items={dropdownMenu} portalProps={dropdownPortalProps}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</DropdownMenu>
);

View File

@@ -1,6 +1,7 @@
import { Input, Popover, stopPropagation } from '@lobehub/ui';
import { memo, useCallback, useState } from 'react';
import { useOverlayPopoverPortalProps } from '@/features/NavPanel/OverlayContainer';
import { useChatStore } from '@/store/chat';
interface EditingProps {
@@ -15,6 +16,7 @@ const Editing = memo<EditingProps>(({ id, title, toggleEditing }) => {
s.threadRenamingId === id,
s.updateThreadTitle,
]);
const popoverPortalProps = useOverlayPopoverPortalProps();
const handleUpdate = useCallback(async () => {
if (newTitle && title !== newTitle) {
@@ -27,6 +29,7 @@ const Editing = memo<EditingProps>(({ id, title, toggleEditing }) => {
<Popover
open={editing}
placement="bottomLeft"
portalProps={popoverPortalProps}
trigger="click"
content={
<Input

View File

@@ -1,10 +1,11 @@
'use client';
import { Modal } from '@lobehub/ui';
import { type PropsWithChildren } from 'react';
import { memo } from 'react';
import type { PropsWithChildren } from 'react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { OverlayContainerContext } from '@/features/NavPanel/OverlayContainer';
import { useFetchTopics } from '@/hooks/useFetchTopics';
import { useWorkspaceModal } from '@/hooks/useWorkspaceModal';
import { useGlobalStore } from '@/store/global';
@@ -17,22 +18,27 @@ const Topics = memo(({ children }: PropsWithChildren) => {
]);
const [open, setOpen] = useWorkspaceModal(showAgentSettings, toggleConfig);
const { t } = useTranslation('topic');
const [overlayContainer, setOverlayContainer] = useState<HTMLDivElement | null>(null);
useFetchTopics();
return (
<Modal
allowFullscreen
footer={null}
open={open}
title={t('title')}
styles={{
body: { padding: 0 },
}}
onCancel={() => setOpen(false)}
>
{children}
</Modal>
<OverlayContainerContext value={overlayContainer}>
<Modal
allowFullscreen
footer={null}
open={open}
title={t('title')}
styles={{
body: { padding: 0 },
}}
onCancel={() => setOpen(false)}
>
<div ref={setOverlayContainer} style={{ height: '100%' }}>
{children}
</div>
</Modal>
</OverlayContainerContext>
);
});