mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ feat(chat-input): add category-based mention menu (#13109)
* ✨ feat(chat-input): add category-based mention menu with keyboard navigation Replace flat mention list with a structured category menu (Agents, Members, Topics). Supports home/category/search views, Fuse.js fuzzy search, floating-ui positioning, and full keyboard navigation. * 🔧 chore: update @lobehub/editor to version 4.3.0 and refactor type definition in useMentionCategories Signed-off-by: Innei <tukon479@gmail.com> * ✨ feat(MentionMenu): enhance icon rendering logic in MenuItem component Updated the MenuItem component to improve how icons are rendered. Now, it checks if the icon is a valid React element or a function, ensuring better flexibility in icon usage. This change enhances the overall user experience in the mention menu. Signed-off-by: Innei <tukon479@gmail.com> * 🔧 chore: update @lobehub/editor to version 4.3.1 in package.json Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -188,6 +188,7 @@
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@fal-ai/client": "^1.8.4",
|
||||
"@floating-ui/react": "^0.27.19",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@google/genai": "^1.38.0",
|
||||
"@henrygd/queue": "^1.2.0",
|
||||
@@ -253,7 +254,7 @@
|
||||
"@lobehub/chat-plugin-sdk": "^1.32.4",
|
||||
"@lobehub/chat-plugins-gateway": "^1.9.0",
|
||||
"@lobehub/desktop-ipc-typings": "workspace:*",
|
||||
"@lobehub/editor": "^4.2.0",
|
||||
"@lobehub/editor": "^4.3.1",
|
||||
"@lobehub/icons": "^5.0.0",
|
||||
"@lobehub/market-sdk": "^0.31.3",
|
||||
"@lobehub/tts": "^5.1.2",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ISlashMenuOption } from '@lobehub/editor';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import MenuItem from './MenuItem';
|
||||
import { useStyles } from './style';
|
||||
import type { MentionCategory } from './types';
|
||||
|
||||
interface CategoryViewProps {
|
||||
activeKey: string | null;
|
||||
category: MentionCategory;
|
||||
onBack: () => void;
|
||||
onSelectItem: (item: ISlashMenuOption) => void;
|
||||
}
|
||||
|
||||
const CategoryView = memo<CategoryViewProps>(({ category, activeKey, onSelectItem, onBack }) => {
|
||||
const { styles } = useStyles();
|
||||
const handleMouseDown = (event: MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={styles.backHeader}
|
||||
role="presentation"
|
||||
onClick={onBack}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
{category.label}
|
||||
</div>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.scrollArea}>
|
||||
{category.items.map((item) => (
|
||||
<MenuItem
|
||||
active={String(item.key) === activeKey}
|
||||
item={item}
|
||||
key={item.key}
|
||||
onClick={onSelectItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
CategoryView.displayName = 'CategoryView';
|
||||
|
||||
export default CategoryView;
|
||||
50
src/features/ChatInput/InputEditor/MentionMenu/HomeView.tsx
Normal file
50
src/features/ChatInput/InputEditor/MentionMenu/HomeView.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ISlashMenuOption } from '@lobehub/editor';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import MenuItem from './MenuItem';
|
||||
import { useStyles } from './style';
|
||||
import { isCategoryEntry } from './types';
|
||||
|
||||
interface HomeViewProps {
|
||||
activeKey: string | null;
|
||||
dividerIndex: number;
|
||||
onSelectItem: (item: ISlashMenuOption) => void;
|
||||
visibleItems: ISlashMenuOption[];
|
||||
}
|
||||
|
||||
const HomeView = memo<HomeViewProps>(({ visibleItems, activeKey, onSelectItem, dividerIndex }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.scrollArea}>
|
||||
{visibleItems.map((item, idx) => {
|
||||
const isCategory = isCategoryEntry(String(item.key));
|
||||
const showDivider = idx === dividerIndex && dividerIndex > 0;
|
||||
|
||||
return (
|
||||
<div key={item.key}>
|
||||
{showDivider && <div className={styles.divider} />}
|
||||
<MenuItem
|
||||
active={String(item.key) === activeKey}
|
||||
item={item}
|
||||
extra={
|
||||
isCategory ? (
|
||||
<span className={styles.categoryExtra}>
|
||||
{(item as any).metadata?.count}
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
onClick={onSelectItem}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
HomeView.displayName = 'HomeView';
|
||||
|
||||
export default HomeView;
|
||||
48
src/features/ChatInput/InputEditor/MentionMenu/MenuItem.tsx
Normal file
48
src/features/ChatInput/InputEditor/MentionMenu/MenuItem.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ISlashMenuOption } from '@lobehub/editor';
|
||||
import { cx } from 'antd-style';
|
||||
import { createElement, isValidElement, type MouseEvent, type ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface MenuItemProps {
|
||||
active?: boolean;
|
||||
extra?: ReactNode;
|
||||
item: ISlashMenuOption;
|
||||
onClick: (item: ISlashMenuOption) => void;
|
||||
}
|
||||
|
||||
const MenuItem = memo<MenuItemProps>(({ item, active, extra, onClick }) => {
|
||||
const { styles } = useStyles();
|
||||
const handleMouseDown = (event: MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-selected={active}
|
||||
className={cx(styles.item, active && styles.itemActive)}
|
||||
data-key={item.key}
|
||||
id={`mention-item-${item.key}`}
|
||||
role="option"
|
||||
onClick={() => onClick(item)}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className={styles.itemIcon}>
|
||||
{isValidElement(item.icon)
|
||||
? item.icon
|
||||
: typeof item.icon === 'function'
|
||||
? createElement(item.icon)
|
||||
: item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.itemLabel}>{item.label}</span>
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MenuItem.displayName = 'MenuItem';
|
||||
|
||||
export default MenuItem;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ISlashMenuOption } from '@lobehub/editor';
|
||||
import { memo } from 'react';
|
||||
|
||||
import MenuItem from './MenuItem';
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface SearchViewProps {
|
||||
activeKey: string | null;
|
||||
onSelectItem: (item: ISlashMenuOption) => void;
|
||||
options: ISlashMenuOption[];
|
||||
}
|
||||
|
||||
const SearchView = memo<SearchViewProps>(({ options, activeKey, onSelectItem }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
if (options.length === 0) {
|
||||
return <div className={styles.empty}>No results</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.scrollArea}>
|
||||
{options.map((item) => (
|
||||
<MenuItem
|
||||
active={String(item.key) === activeKey}
|
||||
item={item}
|
||||
key={item.key}
|
||||
onClick={onSelectItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SearchView.displayName = 'SearchView';
|
||||
|
||||
export default SearchView;
|
||||
196
src/features/ChatInput/InputEditor/MentionMenu/index.tsx
Normal file
196
src/features/ChatInput/InputEditor/MentionMenu/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { ISlashMenuOption, ISlashOption } from '@lobehub/editor';
|
||||
import { LOBE_THEME_APP_ID } from '@lobehub/ui';
|
||||
import type { FC, RefObject } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import CategoryView from './CategoryView';
|
||||
import HomeView from './HomeView';
|
||||
import SearchView from './SearchView';
|
||||
import { useStyles } from './style';
|
||||
import type { MentionCategory, MentionCategoryId, MentionMenuState } from './types';
|
||||
import { CATEGORY_KEY_PREFIX, getCategoryIdFromKey, isCategoryEntry } from './types';
|
||||
import { useKeyboardNav } from './useKeyboardNav';
|
||||
import { useMenuPosition } from './useMenuPosition';
|
||||
|
||||
const RECENT_COUNT = 8;
|
||||
|
||||
interface MenuRenderProps {
|
||||
activeKey: string | null;
|
||||
loading?: boolean;
|
||||
onSelect?: (option: ISlashMenuOption) => void;
|
||||
open?: boolean;
|
||||
options: Array<ISlashOption>;
|
||||
setActiveKey: (key: string | null) => void;
|
||||
}
|
||||
|
||||
const getRecentItems = (options: ISlashMenuOption[], count: number): ISlashMenuOption[] => {
|
||||
return [...options]
|
||||
.sort((a, b) => {
|
||||
const ta = (a as any).metadata?.timestamp || 0;
|
||||
const tb = (b as any).metadata?.timestamp || 0;
|
||||
return tb - ta;
|
||||
})
|
||||
.slice(0, count);
|
||||
};
|
||||
|
||||
const buildCategoryEntries = (categories: MentionCategory[]): ISlashMenuOption[] =>
|
||||
categories
|
||||
.filter((c) => c.items.length > 0)
|
||||
.map((cat) => ({
|
||||
icon: cat.icon,
|
||||
key: `${CATEGORY_KEY_PREFIX}${cat.id}`,
|
||||
label: cat.label,
|
||||
metadata: { categoryId: cat.id, count: cat.items.length, type: '__category__' },
|
||||
}));
|
||||
|
||||
export const createMentionMenu = (
|
||||
stateRef: RefObject<MentionMenuState>,
|
||||
categoriesRef: RefObject<MentionCategory[]>,
|
||||
): FC<MenuRenderProps> => {
|
||||
const MentionMenu: FC<MenuRenderProps> = memo(
|
||||
({ activeKey, onSelect, open, options, setActiveKey }) => {
|
||||
const { styles } = useStyles();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [viewMode, setViewMode] = useState<'home' | 'category'>('home');
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<MentionCategoryId | null>(null);
|
||||
|
||||
const isSearch = stateRef.current.isSearch;
|
||||
const categories = categoriesRef.current;
|
||||
|
||||
const position = useMenuPosition(menuRef, !!open);
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setViewMode('home');
|
||||
setSelectedCategoryId(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Filter options to only ISlashMenuOption (exclude dividers)
|
||||
const menuOptions = useMemo(
|
||||
() => options.filter((o): o is ISlashMenuOption => 'key' in o && !!o.key),
|
||||
[options],
|
||||
);
|
||||
|
||||
// Category entries as pseudo-items for keyboard navigation
|
||||
const categoryEntries = useMemo(() => buildCategoryEntries(categories), [categories]);
|
||||
|
||||
// Derive visible items for current view
|
||||
const visibleItems = useMemo((): ISlashMenuOption[] => {
|
||||
if (isSearch) return menuOptions;
|
||||
|
||||
if (viewMode === 'category' && selectedCategoryId) {
|
||||
const cat = categories.find((c) => c.id === selectedCategoryId);
|
||||
return cat?.items || [];
|
||||
}
|
||||
|
||||
// Home: recent items + category entries (unified list)
|
||||
const recent = getRecentItems(menuOptions, RECENT_COUNT);
|
||||
return [...recent, ...categoryEntries];
|
||||
}, [menuOptions, isSearch, viewMode, selectedCategoryId, categories, categoryEntries]);
|
||||
|
||||
// Sync activeKey on view/options change
|
||||
useEffect(() => {
|
||||
if (open && visibleItems.length > 0) {
|
||||
setActiveKey(String(visibleItems[0].key));
|
||||
}
|
||||
}, [open, viewMode, selectedCategoryId, isSearch, visibleItems, setActiveKey]);
|
||||
|
||||
const handleSelectCategory = useCallback((id: MentionCategoryId) => {
|
||||
setViewMode('category');
|
||||
setSelectedCategoryId(id);
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setViewMode('home');
|
||||
setSelectedCategoryId(null);
|
||||
}, []);
|
||||
|
||||
// Item selection — intercept category entries
|
||||
const handleSelectItem = useCallback(
|
||||
(item: ISlashMenuOption) => {
|
||||
const key = String(item.key);
|
||||
if (isCategoryEntry(key)) {
|
||||
handleSelectCategory(getCategoryIdFromKey(key));
|
||||
return;
|
||||
}
|
||||
onSelect?.(item);
|
||||
},
|
||||
[onSelect, handleSelectCategory],
|
||||
);
|
||||
|
||||
const effectiveMode = isSearch ? 'search' : viewMode;
|
||||
|
||||
useKeyboardNav({
|
||||
activeKey,
|
||||
mode: effectiveMode === 'search' ? 'search' : viewMode,
|
||||
onBack: handleBack,
|
||||
onSelect: handleSelectItem,
|
||||
open: !!open,
|
||||
setActiveKey,
|
||||
visibleItems,
|
||||
});
|
||||
|
||||
const lobeApp = useMemo(
|
||||
() => document.getElementById(LOBE_THEME_APP_ID) ?? document.body,
|
||||
[],
|
||||
);
|
||||
if (!open) return null;
|
||||
|
||||
const selectedCategory = selectedCategoryId
|
||||
? categories.find((c) => c.id === selectedCategoryId)
|
||||
: null;
|
||||
|
||||
// Index where category entries start (for divider placement in HomeView)
|
||||
const recentCount =
|
||||
effectiveMode === 'home' ? visibleItems.length - categoryEntries.length : 0;
|
||||
|
||||
const menu = (
|
||||
<div
|
||||
aria-activedescendant={activeKey ? `mention-item-${activeKey}` : undefined}
|
||||
className={styles.container}
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
style={{
|
||||
left: position.x,
|
||||
opacity: position.visible ? 1 : 0,
|
||||
pointerEvents: position.visible ? 'auto' : 'none',
|
||||
top: position.y,
|
||||
visibility: position.visible ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{effectiveMode === 'home' && (
|
||||
<HomeView
|
||||
activeKey={activeKey}
|
||||
dividerIndex={recentCount}
|
||||
visibleItems={visibleItems}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
)}
|
||||
{effectiveMode === 'category' && selectedCategory && (
|
||||
<CategoryView
|
||||
activeKey={activeKey}
|
||||
category={selectedCategory}
|
||||
onBack={handleBack}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
)}
|
||||
{effectiveMode === 'search' && (
|
||||
<SearchView
|
||||
activeKey={activeKey}
|
||||
options={visibleItems}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(menu, lobeApp);
|
||||
},
|
||||
);
|
||||
|
||||
MentionMenu.displayName = 'MentionMenu';
|
||||
return MentionMenu;
|
||||
};
|
||||
103
src/features/ChatInput/InputEditor/MentionMenu/style.ts
Normal file
103
src/features/ChatInput/InputEditor/MentionMenu/style.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
|
||||
export const useStyles = createStyles(({ css, token, isDarkMode }) => ({
|
||||
backHeader: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${token.colorTextSecondary};
|
||||
|
||||
&:hover {
|
||||
color: ${token.colorText};
|
||||
}
|
||||
`,
|
||||
categoryExtra: css`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextQuaternary};
|
||||
`,
|
||||
container: css`
|
||||
position: fixed;
|
||||
z-index: 99999;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
max-height: 360px;
|
||||
padding: 4px;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
border-radius: 10px;
|
||||
|
||||
background: ${isDarkMode ? token.colorBgElevated : token.colorBgContainer};
|
||||
box-shadow: ${token.boxShadowSecondary};
|
||||
`,
|
||||
divider: css`
|
||||
height: 1px;
|
||||
margin-block: 4px;
|
||||
margin-inline: 8px;
|
||||
background: ${token.colorBorder};
|
||||
`,
|
||||
empty: css`
|
||||
padding-block: 16px;
|
||||
padding-inline: 12px;
|
||||
|
||||
font-size: 13px;
|
||||
color: ${token.colorTextQuaternary};
|
||||
text-align: center;
|
||||
`,
|
||||
item: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
font-size: 13px;
|
||||
|
||||
transition: background 0.1s;
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
itemActive: css`
|
||||
background: ${token.colorFillSecondary};
|
||||
`,
|
||||
itemIcon: css`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`,
|
||||
itemLabel: css`
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
scrollArea: css`
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
`,
|
||||
}));
|
||||
23
src/features/ChatInput/InputEditor/MentionMenu/types.ts
Normal file
23
src/features/ChatInput/InputEditor/MentionMenu/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ISlashMenuOption } from '@lobehub/editor';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type MentionCategoryId = 'agent' | 'topic' | 'member';
|
||||
|
||||
export interface MentionCategory {
|
||||
icon: ReactNode;
|
||||
id: MentionCategoryId;
|
||||
items: ISlashMenuOption[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MentionMenuState {
|
||||
isSearch: boolean;
|
||||
matchingString: string;
|
||||
}
|
||||
|
||||
export const CATEGORY_KEY_PREFIX = '__category__';
|
||||
|
||||
export const isCategoryEntry = (key: string): boolean => key.startsWith(CATEGORY_KEY_PREFIX);
|
||||
|
||||
export const getCategoryIdFromKey = (key: string): MentionCategoryId =>
|
||||
key.slice(CATEGORY_KEY_PREFIX.length) as MentionCategoryId;
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ISlashMenuOption } from '@lobehub/editor';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface KeyboardNavOptions {
|
||||
activeKey: string | null;
|
||||
mode: 'home' | 'category' | 'search';
|
||||
onBack: () => void;
|
||||
onSelect: (option: ISlashMenuOption) => void;
|
||||
open: boolean;
|
||||
setActiveKey: (key: string | null) => void;
|
||||
visibleItems: ISlashMenuOption[];
|
||||
}
|
||||
|
||||
export const useKeyboardNav = ({
|
||||
open,
|
||||
visibleItems,
|
||||
activeKey,
|
||||
setActiveKey,
|
||||
onSelect,
|
||||
onBack,
|
||||
mode,
|
||||
}: KeyboardNavOptions) => {
|
||||
useEffect(() => {
|
||||
if (!open || visibleItems.length === 0) return;
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
const idx = visibleItems.findIndex((i) => String(i.key) === activeKey);
|
||||
const next =
|
||||
e.key === 'ArrowDown'
|
||||
? (idx + 1) % visibleItems.length
|
||||
: (idx - 1 + visibleItems.length) % visibleItems.length;
|
||||
|
||||
const nextKey = String(visibleItems[next].key);
|
||||
setActiveKey(nextKey);
|
||||
|
||||
// Scroll active item into view
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(`mention-item-${nextKey}`)?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
const item = visibleItems.find((i) => String(i.key) === activeKey);
|
||||
if (item) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
onSelect(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && mode === 'category') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
onBack();
|
||||
}
|
||||
// Home/Search Escape: do NOT intercept → let editor close the menu
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler, true); // capture phase
|
||||
return () => document.removeEventListener('keydown', handler, true);
|
||||
}, [open, visibleItems, activeKey, setActiveKey, onSelect, onBack, mode]);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { VirtualElement } from '@floating-ui/react';
|
||||
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react';
|
||||
import type { RefObject } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
const MENU_GAP = 4;
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
|
||||
interface MenuPosition {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const getSelectionRect = () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection?.rangeCount) return new DOMRect();
|
||||
|
||||
return selection.getRangeAt(0).getBoundingClientRect();
|
||||
};
|
||||
|
||||
const hasActiveEditorSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return false;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) return false;
|
||||
|
||||
const editor = activeElement.closest('[data-lexical-editor="true"]');
|
||||
if (!(editor instanceof HTMLElement)) return false;
|
||||
|
||||
const anchorNode = selection.anchorNode;
|
||||
if (!anchorNode) return false;
|
||||
|
||||
const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode.parentElement;
|
||||
|
||||
return !!anchorElement && editor.contains(anchorElement);
|
||||
};
|
||||
|
||||
const createCaretVirtualElement = (): VirtualElement => ({
|
||||
getBoundingClientRect: () => getSelectionRect(),
|
||||
});
|
||||
|
||||
export const useMenuPosition = (menuRef: RefObject<HTMLDivElement | null>, open: boolean) => {
|
||||
const { refs, update, x, y } = useFloating({
|
||||
middleware: [
|
||||
offset(MENU_GAP),
|
||||
flip({ fallbackPlacements: ['bottom-start'] }),
|
||||
shift({ crossAxis: true, mainAxis: false, padding: VIEWPORT_MARGIN }),
|
||||
],
|
||||
placement: 'top-start',
|
||||
strategy: 'fixed',
|
||||
});
|
||||
const [position, setPosition] = useState<MenuPosition>({ visible: false });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !menuRef.current) {
|
||||
setPosition((prev) => ({ ...prev, visible: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = menuRef.current;
|
||||
const reference = createCaretVirtualElement();
|
||||
refs.setFloating(menu);
|
||||
refs.setPositionReference(reference);
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!hasActiveEditorSelection()) {
|
||||
setPosition({ visible: false });
|
||||
return;
|
||||
}
|
||||
|
||||
await update();
|
||||
setPosition({ visible: true });
|
||||
};
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
requestAnimationFrame(() => {
|
||||
void updatePosition();
|
||||
});
|
||||
};
|
||||
|
||||
const cleanupAutoUpdate = autoUpdate(reference, menu, scheduleUpdate, {
|
||||
animationFrame: true,
|
||||
});
|
||||
|
||||
document.addEventListener('focusin', scheduleUpdate);
|
||||
document.addEventListener('focusout', scheduleUpdate);
|
||||
document.addEventListener('selectionchange', scheduleUpdate);
|
||||
scheduleUpdate();
|
||||
|
||||
return () => {
|
||||
cleanupAutoUpdate();
|
||||
document.removeEventListener('focusin', scheduleUpdate);
|
||||
document.removeEventListener('focusout', scheduleUpdate);
|
||||
document.removeEventListener('selectionchange', scheduleUpdate);
|
||||
};
|
||||
}, [menuRef, open, refs, update]);
|
||||
|
||||
return { visible: position.visible, x: x ?? 0, y: y ?? 0 };
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { HotkeyEnum, KeyEnum } from '@lobechat/types';
|
||||
import { isCommandPressed } from '@lobechat/utils';
|
||||
import { INSERT_MENTION_COMMAND, ReactMathPlugin, type SlashOptions } from '@lobehub/editor';
|
||||
import { INSERT_MENTION_COMMAND, ReactMathPlugin } from '@lobehub/editor';
|
||||
import { Editor, FloatMenu, useEditorState } from '@lobehub/editor/react';
|
||||
import { combineKeys } from '@lobehub/ui';
|
||||
import { css, cx } from 'antd-style';
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
|
||||
import { usePasteFile, useUploadFiles } from '@/components/DragUploadZone';
|
||||
@@ -18,11 +19,12 @@ import { labPreferSelectors, preferenceSelectors, settingsSelectors } from '@/st
|
||||
import { useAgentId } from '../hooks/useAgentId';
|
||||
import { useChatInputStore, useStoreApi } from '../store';
|
||||
import { useSlashActionItems } from './ActionTag';
|
||||
import { createMentionMenu } from './MentionMenu';
|
||||
import type { MentionMenuState } from './MentionMenu/types';
|
||||
import Placeholder from './Placeholder';
|
||||
import { CHAT_INPUT_EMBED_PLUGINS, createChatInputRichPlugins } from './plugins';
|
||||
import { INSERT_REFER_TOPIC_COMMAND } from './ReferTopic';
|
||||
import { useAgentMentionItems } from './useAgentMentionItems';
|
||||
import { useTopicMentionItems } from './useTopicMentionItems';
|
||||
import { useMentionCategories } from './useMentionCategories';
|
||||
|
||||
const className = cx(css`
|
||||
p {
|
||||
@@ -31,14 +33,13 @@ const className = cx(css`
|
||||
`);
|
||||
|
||||
const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
|
||||
const [editor, slashMenuRef, send, updateMarkdownContent, expand, mentionItems, slashPlacement] =
|
||||
const [editor, slashMenuRef, send, updateMarkdownContent, expand, slashPlacement] =
|
||||
useChatInputStore((s) => [
|
||||
s.editor,
|
||||
s.slashMenuRef,
|
||||
s.handleSendButton,
|
||||
s.updateMarkdownContent,
|
||||
s.expand,
|
||||
s.mentionItems,
|
||||
s.slashPlacement ?? 'top',
|
||||
]);
|
||||
|
||||
@@ -51,32 +52,40 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
|
||||
|
||||
const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
|
||||
|
||||
const topicMentionItems = useTopicMentionItems();
|
||||
const agentMentionItems = useAgentMentionItems();
|
||||
// --- Category-based mention system ---
|
||||
const categories = useMentionCategories();
|
||||
const stateRef = useRef<MentionMenuState>({ isSearch: false, matchingString: '' });
|
||||
const categoriesRef = useRef(categories);
|
||||
categoriesRef.current = categories;
|
||||
|
||||
const mergedMentionItems = useMemo<SlashOptions['items'] | undefined>(() => {
|
||||
const topicItems = topicMentionItems || [];
|
||||
// In non-group context, use agent items from hook; in group context, use injected mentionItems
|
||||
const fallbackAgentItems = !mentionItems ? agentMentionItems : [];
|
||||
const allMentionItems = useMemo(() => categories.flatMap((c) => c.items), [categories]);
|
||||
|
||||
if (!mentionItems && fallbackAgentItems.length === 0 && topicItems.length === 0)
|
||||
return undefined;
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(allMentionItems, {
|
||||
keys: ['key', 'label', 'metadata.topicTitle'],
|
||||
threshold: 0.3,
|
||||
}),
|
||||
[allMentionItems],
|
||||
);
|
||||
|
||||
if (typeof mentionItems === 'function') {
|
||||
const fn = mentionItems;
|
||||
return async (search: Parameters<typeof fn>[0]) => {
|
||||
const groupItems = await fn(search);
|
||||
return [...groupItems, ...topicItems];
|
||||
};
|
||||
}
|
||||
const mentionItemsFn = useCallback(
|
||||
async (
|
||||
search: { leadOffset: number; matchingString: string; replaceableString: string } | null,
|
||||
) => {
|
||||
if (search?.matchingString) {
|
||||
stateRef.current = { isSearch: true, matchingString: search.matchingString };
|
||||
return fuse.search(search.matchingString).map((r) => r.item);
|
||||
}
|
||||
stateRef.current = { isSearch: false, matchingString: '' };
|
||||
return [...allMentionItems];
|
||||
},
|
||||
[allMentionItems, fuse],
|
||||
);
|
||||
|
||||
const externalItems = Array.isArray(mentionItems) ? mentionItems : [];
|
||||
return [...externalItems, ...fallbackAgentItems, ...topicItems];
|
||||
}, [mentionItems, topicMentionItems, agentMentionItems]);
|
||||
const MentionMenuComp = useMemo(() => createMentionMenu(stateRef, categoriesRef), []);
|
||||
|
||||
const enableMention =
|
||||
!!mergedMentionItems &&
|
||||
(typeof mergedMentionItems === 'function' || mergedMentionItems.length > 0);
|
||||
const enableMention = allMentionItems.length > 0;
|
||||
|
||||
// Get agent's model info for vision support check and handle paste upload
|
||||
const agentId = useAgentId();
|
||||
@@ -156,14 +165,14 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
|
||||
mentionOption={
|
||||
enableMention
|
||||
? {
|
||||
fuseOptions: { keys: ['key', 'label', 'metadata.topicTitle'], threshold: 0.3 },
|
||||
items: mergedMentionItems,
|
||||
items: mentionItemsFn,
|
||||
markdownWriter: (mention) => {
|
||||
if (mention.metadata?.type === 'topic') {
|
||||
return `<refer_topic name="${mention.metadata.topicTitle}" id="${mention.metadata.topicId}" />`;
|
||||
}
|
||||
return `<mention name="${mention.label}" id="${mention.metadata.id}" />`;
|
||||
},
|
||||
maxLength: 50,
|
||||
onSelect: (editor, option) => {
|
||||
if (option.metadata?.type === 'topic') {
|
||||
editor.dispatchCommand(INSERT_REFER_TOPIC_COMMAND, {
|
||||
@@ -177,6 +186,7 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
|
||||
});
|
||||
}
|
||||
},
|
||||
renderComp: MentionMenuComp,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
122
src/features/ChatInput/InputEditor/useMentionCategories.tsx
Normal file
122
src/features/ChatInput/InputEditor/useMentionCategories.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Avatar, Icon } from '@lobehub/ui';
|
||||
import { Bot, MessageSquareText, Users } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
import { homeAgentListSelectors } from '@/store/home/selectors';
|
||||
|
||||
import { useAgentId } from '../hooks/useAgentId';
|
||||
import { useChatInputStore } from '../store';
|
||||
import type { MentionCategory } from './MentionMenu/types';
|
||||
|
||||
const MAX_AGENT_ITEMS = 20;
|
||||
const MAX_TOPIC_LABEL = 50;
|
||||
type MenuOptionWithMetadata = { key: string; metadata?: Record<string, unknown> };
|
||||
|
||||
export const useMentionCategories = (): MentionCategory[] => {
|
||||
const currentAgentId = useAgentId();
|
||||
const allAgents = useHomeStore(homeAgentListSelectors.allAgents);
|
||||
|
||||
const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize);
|
||||
const topicsSelector = useMemo(
|
||||
() => topicSelectors.displayTopicsForSidebar(topicPageSize),
|
||||
[topicPageSize],
|
||||
);
|
||||
const topics = useChatStore(topicsSelector);
|
||||
const activeTopicId = useChatStore((s) => s.activeTopicId);
|
||||
|
||||
const externalMentionItems = useChatInputStore((s) => s.mentionItems);
|
||||
const isGroupChat = !!externalMentionItems;
|
||||
|
||||
return useMemo(() => {
|
||||
const categories: MentionCategory[] = [];
|
||||
|
||||
// --- Agents (non-group only) ---
|
||||
if (!isGroupChat) {
|
||||
const items = allAgents
|
||||
.filter((a) => a.type === 'agent' && a.id !== currentAgentId)
|
||||
.slice(0, MAX_AGENT_ITEMS)
|
||||
.map((agent) => ({
|
||||
icon: (
|
||||
<Avatar
|
||||
avatar={typeof agent.avatar === 'string' ? agent.avatar : undefined}
|
||||
background={agent.backgroundColor ?? undefined}
|
||||
size={24}
|
||||
/>
|
||||
),
|
||||
key: `agent-${agent.id}`,
|
||||
label: agent.title || 'Untitled Agent',
|
||||
metadata: {
|
||||
id: agent.id,
|
||||
timestamp: agent.updatedAt ? new Date(agent.updatedAt).getTime() : 0,
|
||||
type: 'agent' as const,
|
||||
},
|
||||
}));
|
||||
|
||||
if (items.length > 0) {
|
||||
categories.push({
|
||||
id: 'agent',
|
||||
icon: <Icon icon={Bot} size={16} />,
|
||||
items,
|
||||
label: 'Agents',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Members (group chat only) ---
|
||||
if (isGroupChat && Array.isArray(externalMentionItems)) {
|
||||
const items = externalMentionItems
|
||||
.filter((item): item is MenuOptionWithMetadata => 'key' in item && !!item.key)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
metadata: Object.assign({ timestamp: 0, type: 'member' as const }, item.metadata),
|
||||
}));
|
||||
|
||||
if (items.length > 0) {
|
||||
categories.push({
|
||||
id: 'member',
|
||||
icon: <Icon icon={Users} size={16} />,
|
||||
items,
|
||||
label: 'Members',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Topics ---
|
||||
if (topics && topics.length > 0) {
|
||||
const items = topics
|
||||
.filter((t) => t.id !== activeTopicId)
|
||||
.map((topic) => {
|
||||
const title = topic.title || 'Untitled';
|
||||
const label =
|
||||
title.length > MAX_TOPIC_LABEL ? `${title.slice(0, MAX_TOPIC_LABEL)}...` : title;
|
||||
return {
|
||||
icon: <Icon icon={MessageSquareText} size={16} />,
|
||||
key: `topic-${topic.id}`,
|
||||
label,
|
||||
metadata: {
|
||||
topicId: topic.id,
|
||||
topicTitle: topic.title,
|
||||
timestamp: topic.updatedAt || 0,
|
||||
type: 'topic' as const,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (items.length > 0) {
|
||||
categories.push({
|
||||
id: 'topic',
|
||||
icon: <Icon icon={MessageSquareText} size={16} />,
|
||||
items,
|
||||
label: 'Topics',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
}, [allAgents, currentAgentId, topics, activeTopicId, isGroupChat, externalMentionItems]);
|
||||
};
|
||||
Reference in New Issue
Block a user