Files
lobehub/src/features/ChatInput/InputEditor/MentionMenu/useMenuPosition.ts
Innei 80cb6c9d11 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>
2026-03-19 21:48:10 +08:00

101 lines
2.9 KiB
TypeScript

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 };
};