mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
🐛 fix(mobile): render topic menus and rename popovers inside active overlay container (#12477)
This commit is contained in:
@@ -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
|
||||
|
||||
45
src/features/NavPanel/OverlayContainer.ts
Normal file
45
src/features/NavPanel/OverlayContainer.ts
Normal 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]);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user