mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
feat: Polish CMDK (#12011)
* fix: Hide proxy command for web * style: add close transition * fix: cmdk close animation * style: show current theme
This commit is contained in:
@@ -188,6 +188,7 @@
|
||||
"cmdk.submitIssue": "Submit Issue",
|
||||
"cmdk.theme": "Theme",
|
||||
"cmdk.themeAuto": "Auto",
|
||||
"cmdk.themeCurrent": "Current",
|
||||
"cmdk.themeDark": "Dark",
|
||||
"cmdk.themeLight": "Light",
|
||||
"cmdk.toOpen": "Open",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"cmdk.submitIssue": "提交问题",
|
||||
"cmdk.theme": "主题",
|
||||
"cmdk.themeAuto": "跟随系统",
|
||||
"cmdk.themeCurrent": "当前",
|
||||
"cmdk.themeDark": "深色模式",
|
||||
"cmdk.themeLight": "浅色模式",
|
||||
"cmdk.toOpen": "打开",
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ValidSearchType } from './utils/queryParser';
|
||||
interface CommandMenuContextValue {
|
||||
menuContext: MenuContext;
|
||||
mounted: boolean;
|
||||
onClose: () => void;
|
||||
page: PageType | undefined;
|
||||
pages: PageType[];
|
||||
pathname: string | null;
|
||||
@@ -38,10 +39,11 @@ const CommandMenuContext = createContext<CommandMenuContextValue | undefined>(un
|
||||
|
||||
interface CommandMenuProviderProps {
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
pathname: string | null;
|
||||
}
|
||||
|
||||
export const CommandMenuProvider = ({ children, pathname }: CommandMenuProviderProps) => {
|
||||
export const CommandMenuProvider = ({ children, onClose, pathname }: CommandMenuProviderProps) => {
|
||||
const [pages, setPages] = useState<PageType[]>([]);
|
||||
const [search, setSearchState] = useState('');
|
||||
const [typeFilter, setTypeFilterState] = useState<ValidSearchType | undefined>(undefined);
|
||||
@@ -71,6 +73,7 @@ export const CommandMenuProvider = ({ children, pathname }: CommandMenuProviderP
|
||||
() => ({
|
||||
menuContext,
|
||||
mounted: true, // Always true after initial render since provider only mounts on client
|
||||
onClose,
|
||||
page,
|
||||
pages,
|
||||
pathname,
|
||||
@@ -86,6 +89,7 @@ export const CommandMenuProvider = ({ children, pathname }: CommandMenuProviderP
|
||||
}),
|
||||
[
|
||||
menuContext,
|
||||
onClose,
|
||||
page,
|
||||
pages,
|
||||
pathname,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from 'cmdk';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -9,25 +10,41 @@ import { useCommandMenu } from './useCommandMenu';
|
||||
const ThemeMenu = memo(() => {
|
||||
const { t } = useTranslation('common');
|
||||
const { handleThemeChange } = useCommandMenu();
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Command.Item onSelect={() => handleThemeChange('light')} value="theme-light">
|
||||
<Sun className={styles.icon} />
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.itemLabel}>{t('cmdk.themeLight')}</div>
|
||||
<div className={styles.itemDetails}>
|
||||
<div className={styles.itemLabel}>{t('cmdk.themeLight')}</div>
|
||||
{theme === 'light' && (
|
||||
<div className={styles.itemDescription}>{t('cmdk.themeCurrent')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => handleThemeChange('dark')} value="theme-dark">
|
||||
<Moon className={styles.icon} />
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.itemLabel}>{t('cmdk.themeDark')}</div>
|
||||
<div className={styles.itemDetails}>
|
||||
<div className={styles.itemLabel}>{t('cmdk.themeDark')}</div>
|
||||
{theme === 'dark' && (
|
||||
<div className={styles.itemDescription}>{t('cmdk.themeCurrent')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => handleThemeChange('system')} value="theme-system">
|
||||
<Monitor className={styles.icon} />
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.itemLabel}>{t('cmdk.themeAuto')}</div>
|
||||
<div className={styles.itemDetails}>
|
||||
<div className={styles.itemLabel}>{t('cmdk.themeAuto')}</div>
|
||||
{theme === 'system' && (
|
||||
<div className={styles.itemDescription}>{t('cmdk.themeCurrent')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Command.Item>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Avatar } from '@lobehub/ui';
|
||||
import { Command } from 'cmdk';
|
||||
import { CornerDownLeft } from 'lucide-react';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
@@ -21,13 +21,19 @@ import CommandInput from './components/CommandInput';
|
||||
import { styles } from './styles';
|
||||
import { useCommandMenu } from './useCommandMenu';
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 150;
|
||||
|
||||
interface CommandMenuContentProps {
|
||||
isClosing: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that uses the context
|
||||
*/
|
||||
const CommandMenuContent = memo(() => {
|
||||
const CommandMenuContent = memo<CommandMenuContentProps>(({ isClosing, onClose }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const {
|
||||
closeCommandMenu,
|
||||
handleBack,
|
||||
handleSendToSelectedAgent,
|
||||
hasSearch,
|
||||
@@ -61,10 +67,11 @@ const CommandMenuContent = memo(() => {
|
||||
}, [page, setSearch]);
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={closeCommandMenu}>
|
||||
<div className={styles.overlay} data-closing={isClosing} onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Command
|
||||
className={styles.commandRoot}
|
||||
data-closing={isClosing}
|
||||
onKeyDown={(e) => {
|
||||
// Enter key to send message to selected agent
|
||||
if (e.key === 'Enter' && selectedAgent && search.trim()) {
|
||||
@@ -86,7 +93,7 @@ const CommandMenuContent = memo(() => {
|
||||
} else if (pages.length > 0) {
|
||||
handleBack();
|
||||
} else {
|
||||
closeCommandMenu();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
// Backspace clears selected agent when search is empty, or goes to previous page
|
||||
@@ -145,7 +152,7 @@ const CommandMenuContent = memo(() => {
|
||||
{!page && !selectedAgent && hasSearch && !search.trimStart().startsWith('@') && (
|
||||
<SearchResults
|
||||
isLoading={isSearching}
|
||||
onClose={closeCommandMenu}
|
||||
onClose={onClose}
|
||||
onSetTypeFilter={setTypeFilter}
|
||||
results={searchResults}
|
||||
searchQuery={searchQuery}
|
||||
@@ -169,9 +176,11 @@ CommandMenuContent.displayName = 'CommandMenuContent';
|
||||
* Search everything in LobeHub.
|
||||
*/
|
||||
const CommandMenu = memo(() => {
|
||||
const [open] = useGlobalStore((s) => [s.status.showCommandMenu]);
|
||||
const [open, setOpen] = useGlobalStore((s) => [s.status.showCommandMenu, s.updateSystemStatus]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [appRoot, setAppRoot] = useState<HTMLElement | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
|
||||
@@ -180,6 +189,14 @@ const CommandMenu = memo(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Sync visibility with open state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIsVisible(true);
|
||||
setIsClosing(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Find App root node (.ant-app)
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
@@ -217,11 +234,21 @@ const CommandMenu = memo(() => {
|
||||
};
|
||||
}, [mounted]);
|
||||
|
||||
if (!mounted || !open || !appRoot) return null;
|
||||
const handleClose = useCallback(() => {
|
||||
if (isClosing) return;
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setOpen({ showCommandMenu: false });
|
||||
setIsVisible(false);
|
||||
setIsClosing(false);
|
||||
}, CLOSE_ANIMATION_DURATION);
|
||||
}, [isClosing, setOpen]);
|
||||
|
||||
if (!mounted || !isVisible || !appRoot) return null;
|
||||
|
||||
return createPortal(
|
||||
<CommandMenuProvider pathname={pathname}>
|
||||
<CommandMenuContent />
|
||||
<CommandMenuProvider onClose={handleClose} pathname={pathname}>
|
||||
<CommandMenuContent isClosing={isClosing} onClose={handleClose} />
|
||||
</CommandMenuProvider>,
|
||||
appRoot,
|
||||
);
|
||||
|
||||
@@ -22,6 +22,28 @@ const fadeIn = keyframes`
|
||||
}
|
||||
`;
|
||||
|
||||
const slideUp = keyframes`
|
||||
from {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-20px) scale(0.96);
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const fadeOut = keyframes`
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const pulse = keyframes`
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
@@ -92,6 +114,10 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
animation: ${slideDown} 0.12s ease-out;
|
||||
|
||||
&[data-closing='true'] {
|
||||
animation: ${slideUp} 0.15s ease-out forwards;
|
||||
}
|
||||
|
||||
[cmdk-input] {
|
||||
flex: 1;
|
||||
|
||||
@@ -275,6 +301,10 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
background: ${cssVar.colorBgMask};
|
||||
|
||||
animation: ${fadeIn} 0.1s ease-in-out;
|
||||
|
||||
&[data-closing='true'] {
|
||||
animation: ${fadeOut} 0.15s ease-out forwards;
|
||||
}
|
||||
`,
|
||||
skeleton: css`
|
||||
height: 16px;
|
||||
|
||||
@@ -25,9 +25,10 @@ import type { ThemeMode } from './types';
|
||||
* Shared methods for CommandMenu
|
||||
*/
|
||||
export const useCommandMenu = () => {
|
||||
const [open, setOpen] = useGlobalStore((s) => [s.status.showCommandMenu, s.updateSystemStatus]);
|
||||
const [open] = useGlobalStore((s) => [s.status.showCommandMenu]);
|
||||
const {
|
||||
mounted,
|
||||
onClose,
|
||||
search,
|
||||
setSearch,
|
||||
pages,
|
||||
@@ -97,15 +98,15 @@ export const useCommandMenu = () => {
|
||||
}, [open]);
|
||||
|
||||
const closeCommandMenu = useCallback(() => {
|
||||
setOpen({ showCommandMenu: false });
|
||||
}, [setOpen]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
navigate(path);
|
||||
setOpen({ showCommandMenu: false });
|
||||
onClose();
|
||||
},
|
||||
[navigate, setOpen],
|
||||
[navigate, onClose],
|
||||
);
|
||||
|
||||
const handleExternalLink = useCallback(
|
||||
@@ -115,17 +116,17 @@ export const useCommandMenu = () => {
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
setOpen({ showCommandMenu: false });
|
||||
onClose();
|
||||
},
|
||||
[setOpen],
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(theme: ThemeMode) => {
|
||||
setTheme(theme);
|
||||
setOpen({ showCommandMenu: false });
|
||||
onClose();
|
||||
},
|
||||
[setTheme, setOpen],
|
||||
[setTheme, onClose],
|
||||
);
|
||||
|
||||
const handleAskLobeAI = useCallback(() => {
|
||||
@@ -133,18 +134,18 @@ export const useCommandMenu = () => {
|
||||
if (inboxAgentId && search.trim()) {
|
||||
const message = encodeURIComponent(search.trim());
|
||||
navigate(`/agent/${inboxAgentId}?message=${message}`);
|
||||
setOpen({ showCommandMenu: false });
|
||||
onClose();
|
||||
}
|
||||
}, [inboxAgentId, search, navigate, setOpen]);
|
||||
}, [inboxAgentId, search, navigate, onClose]);
|
||||
|
||||
const handleAIPainting = useCallback(() => {
|
||||
// Navigate to painting page with search as prompt
|
||||
if (search.trim()) {
|
||||
const prompt = encodeURIComponent(search.trim());
|
||||
navigate(`/image?prompt=${prompt}`);
|
||||
setOpen({ showCommandMenu: false });
|
||||
onClose();
|
||||
}
|
||||
}, [search, navigate, setOpen]);
|
||||
}, [search, navigate, onClose]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setPages((prev) => prev.slice(0, -1));
|
||||
@@ -155,9 +156,9 @@ export const useCommandMenu = () => {
|
||||
const message = encodeURIComponent(search.trim());
|
||||
navigate(`/agent/${selectedAgent.id}?message=${message}`);
|
||||
setSelectedAgent(undefined);
|
||||
setOpen({ showCommandMenu: false });
|
||||
onClose();
|
||||
}
|
||||
}, [selectedAgent, search, navigate, setSelectedAgent, setOpen]);
|
||||
}, [selectedAgent, search, navigate, setSelectedAgent, onClose]);
|
||||
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
const result = await createAgent({});
|
||||
@@ -168,32 +169,32 @@ export const useCommandMenu = () => {
|
||||
navigate(`/agent/${result.agentId}`);
|
||||
}
|
||||
|
||||
setOpen({ showCommandMenu: false });
|
||||
}, [createAgent, refreshAgentList, navigate, setOpen]);
|
||||
onClose();
|
||||
}, [createAgent, refreshAgentList, navigate, onClose]);
|
||||
|
||||
const openNewTopicOrSaveTopic = useChatStore((s) => s.openNewTopicOrSaveTopic);
|
||||
|
||||
const handleCreateTopic = useCallback(() => {
|
||||
openNewTopicOrSaveTopic();
|
||||
setOpen({ showCommandMenu: false });
|
||||
}, [openNewTopicOrSaveTopic, setOpen]);
|
||||
onClose();
|
||||
}, [openNewTopicOrSaveTopic, onClose]);
|
||||
|
||||
const handleCreateLibrary = useCallback(() => {
|
||||
setOpen({ showCommandMenu: false });
|
||||
onClose();
|
||||
openCreateLibraryModal({
|
||||
onSuccess: (id) => {
|
||||
navigate(`/resource/library/${id}`);
|
||||
},
|
||||
});
|
||||
}, [setOpen, openCreateLibraryModal, navigate]);
|
||||
}, [onClose, openCreateLibraryModal, navigate]);
|
||||
|
||||
const handleCreatePage = useCallback(async () => {
|
||||
await createPage();
|
||||
setOpen({ showCommandMenu: false });
|
||||
}, [createPage, setOpen]);
|
||||
onClose();
|
||||
}, [createPage, onClose]);
|
||||
|
||||
const handleCreateAgentTeam = useCallback(() => {
|
||||
setOpen({ showCommandMenu: false });
|
||||
onClose();
|
||||
openGroupWizard({
|
||||
onCreateCustom: async (selectedAgents) => {
|
||||
await createGroupWithMembers(selectedAgents);
|
||||
@@ -202,7 +203,7 @@ export const useCommandMenu = () => {
|
||||
await createGroupFromTemplate(templateId, selectedMemberTitles);
|
||||
},
|
||||
});
|
||||
}, [setOpen, openGroupWizard, createGroupWithMembers, createGroupFromTemplate]);
|
||||
}, [onClose, openGroupWizard, createGroupWithMembers, createGroupFromTemplate]);
|
||||
|
||||
return {
|
||||
closeCommandMenu,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Brain,
|
||||
@@ -90,16 +91,20 @@ export const CONTEXT_COMMANDS: Record<ContextType, ContextCommand[]> = {
|
||||
path: '/settings/image',
|
||||
subPath: 'image',
|
||||
},
|
||||
{
|
||||
icon: EthernetPort,
|
||||
keywords: ['proxy', 'network', 'connection'],
|
||||
keywordsKey: 'cmdk.keywords.proxy',
|
||||
label: 'Proxy',
|
||||
labelKey: 'tab.proxy',
|
||||
labelNamespace: 'setting',
|
||||
path: '/settings/proxy',
|
||||
subPath: 'proxy',
|
||||
},
|
||||
...(isDesktop
|
||||
? [
|
||||
{
|
||||
icon: EthernetPort,
|
||||
keywords: ['proxy', 'network', 'connection'],
|
||||
keywordsKey: 'cmdk.keywords.proxy',
|
||||
label: 'Proxy',
|
||||
labelKey: 'tab.proxy',
|
||||
labelNamespace: 'setting' as const,
|
||||
path: '/settings/proxy',
|
||||
subPath: 'proxy',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: ChartColumnBigIcon,
|
||||
keywords: ['stats', 'statistics', 'analytics'],
|
||||
|
||||
@@ -253,6 +253,7 @@ export default {
|
||||
'cmdk.submitIssue': 'Submit Issue',
|
||||
'cmdk.theme': 'Theme',
|
||||
'cmdk.themeAuto': 'Auto',
|
||||
'cmdk.themeCurrent': 'Current',
|
||||
'cmdk.themeDark': 'Dark',
|
||||
'cmdk.themeLight': 'Light',
|
||||
'cmdk.toOpen': 'Open',
|
||||
|
||||
Reference in New Issue
Block a user