+
{
+ isComposingRef.current = true;
+ }}
+ onCompositionEnd={() => {
+ isComposingRef.current = false;
+ handleInput();
+ }}
+ data-placeholder={placeholder}
+ className={cn(
+ 'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent overflow-y-auto bg-transparent',
+ 'empty:before:content-[attr(data-placeholder)] empty:before:text-gray-400 empty:before:pointer-events-none',
+ disabled && 'cursor-not-allowed opacity-50',
+ className
+ )}
+ style={{ maxHeight }}
+ suppressContentEditableWarning
+ />
+
+ {showPopover && (
+
+ )}
+
+ );
+};
+
+export default MentionInput;
+export * from './types';
diff --git a/src/components/ui/MentionInput/types.ts b/src/components/ui/MentionInput/types.ts
new file mode 100644
index 0000000..bdb331b
--- /dev/null
+++ b/src/components/ui/MentionInput/types.ts
@@ -0,0 +1,34 @@
+export interface MentionOption {
+ label: string;
+ value: string;
+}
+
+export interface MentionInputProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ onEnter?: (value: string) => void;
+ options: MentionOption[];
+ placeholder?: string;
+ className?: string;
+ mentionClassName?: string;
+ popoverClassName?: string;
+ popoverItemClassName?: string;
+ popoverPlacement?: 'auto' | 'top' | 'bottom';
+ popoverItemRender?: (option: MentionOption, isSelected: boolean) => React.ReactNode;
+ disabled?: boolean;
+ maxHeight?: string;
+}
+
+export interface ParsedSegment {
+ type: 'text' | 'mention';
+ content: string;
+ label?: string;
+ value?: string;
+}
+
+export interface PopoverPosition {
+ top: number;
+ left: number;
+ placement: 'top' | 'bottom';
+}
+
diff --git a/src/components/ui/MentionInput/useCursorPosition.ts b/src/components/ui/MentionInput/useCursorPosition.ts
new file mode 100644
index 0000000..a3996d6
--- /dev/null
+++ b/src/components/ui/MentionInput/useCursorPosition.ts
@@ -0,0 +1,126 @@
+import { RefObject } from 'react';
+import { useMemoizedFn } from 'ahooks';
+
+/**
+ * 光标位置管理 Hook
+ * 处理 contentEditable 元素中的光标位置获取和设置
+ */
+export function useCursorPosition(inputRef: RefObject
) {
+ /**
+ * 获取光标位置对应的文本位置
+ */
+ const getCursorTextPosition = useMemoizedFn(() => {
+ try {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0 || !inputRef.current) return 0;
+
+ const range = selection.getRangeAt(0);
+
+ // 检查选区是否在输入框内
+ if (!inputRef.current.contains(range.commonAncestorContainer)) {
+ return 0;
+ }
+
+ const preCaretRange = range.cloneRange();
+ preCaretRange.selectNodeContents(inputRef.current);
+ preCaretRange.setEnd(range.endContainer, range.endOffset);
+
+ const tempDiv = document.createElement('div');
+ tempDiv.appendChild(preCaretRange.cloneContents());
+
+ // 处理提及元素
+ const mentions = tempDiv.querySelectorAll('[data-mention]');
+ mentions.forEach((mention) => {
+ const value = mention.getAttribute('data-mention');
+ const label = mention.textContent?.replace('@', '') || '';
+ mention.textContent = `@{${value}|${label}}`;
+ });
+
+ return tempDiv.textContent?.length || 0;
+ } catch (e) {
+ console.warn('Failed to get cursor text position:', e);
+ return 0;
+ }
+ });
+
+ /**
+ * 设置光标位置
+ */
+ const setCursorTextPosition = useMemoizedFn((position: number) => {
+ if (!inputRef.current) return;
+
+ try {
+ const range = document.createRange();
+ const selection = window.getSelection();
+ if (!selection) return;
+
+ let currentPos = 0;
+ let targetNode: Node | null = null;
+ let targetOffset = 0;
+
+ const traverse = (node: Node): boolean => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const textLength = node.textContent?.length || 0;
+ if (currentPos + textLength >= position) {
+ targetNode = node;
+ targetOffset = position - currentPos;
+ return true;
+ }
+ currentPos += textLength;
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
+ const element = node as HTMLElement;
+ if (element.hasAttribute('data-mention')) {
+ const mentionValue = element.getAttribute('data-mention') || '';
+ const mentionLabel = element.textContent?.replace('@', '') || '';
+ const mentionLength = `@{${mentionValue}|${mentionLabel}}`.length;
+ if (currentPos + mentionLength >= position) {
+ // 将光标放在提及标签之后
+ targetNode = node.parentNode;
+ if (targetNode && node.parentNode) {
+ const siblings = Array.from(node.parentNode.childNodes);
+ targetOffset = siblings.indexOf(node as ChildNode) + 1;
+ }
+ return true;
+ }
+ currentPos += mentionLength;
+ } else {
+ for (let i = 0; i < node.childNodes.length; i++) {
+ if (traverse(node.childNodes[i])) return true;
+ }
+ }
+ }
+ return false;
+ };
+
+ traverse(inputRef.current);
+
+ if (targetNode) {
+ try {
+ range.setStart(targetNode, targetOffset);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ } catch (e) {
+ console.warn('Failed to set range:', e);
+ // 如果设置失败,尝试将光标放在末尾
+ try {
+ range.selectNodeContents(inputRef.current);
+ range.collapse(false);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ } catch (fallbackError) {
+ console.error('Failed to set cursor position (fallback):', fallbackError);
+ }
+ }
+ }
+ } catch (e) {
+ console.error('Failed to set cursor position:', e);
+ }
+ });
+
+ return {
+ getCursorTextPosition,
+ setCursorTextPosition,
+ };
+}
+
diff --git a/src/components/ui/MentionInput/useMentionInput.ts b/src/components/ui/MentionInput/useMentionInput.ts
new file mode 100644
index 0000000..abacae9
--- /dev/null
+++ b/src/components/ui/MentionInput/useMentionInput.ts
@@ -0,0 +1,172 @@
+import { useState, RefObject } from 'react';
+import { useMemoizedFn } from 'ahooks';
+import { MentionOption, PopoverPosition } from './types';
+import { extractTextFromHTML, shouldShowMentionPopover, filterOptions } from './utils';
+import { calculatePopoverPosition } from './usePopoverPosition';
+
+interface UseMentionInputProps {
+ value: string;
+ options: MentionOption[];
+ onChange?: (value: string) => void;
+ onEnter?: (value: string) => void;
+ popoverPlacement: 'auto' | 'top' | 'bottom';
+ inputRef: RefObject;
+ isComposingRef: RefObject;
+ getCursorTextPosition: () => number;
+ setCursorTextPosition: (position: number) => void;
+}
+
+/**
+ * 提及输入核心逻辑 Hook
+ * 处理输入、提及插入、键盘事件等
+ */
+export function useMentionInput({
+ value,
+ options,
+ onChange,
+ onEnter,
+ popoverPlacement,
+ inputRef,
+ isComposingRef,
+ getCursorTextPosition,
+ setCursorTextPosition,
+}: UseMentionInputProps) {
+ const [showPopover, setShowPopover] = useState(false);
+ const [filteredOptions, setFilteredOptions] = useState([]);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [cursorPosition, setCursorPosition] = useState(0);
+ const [popoverPosition, setPopoverPosition] = useState({
+ top: 0,
+ left: 0,
+ placement: 'bottom',
+ });
+
+ /**
+ * 处理输入事件
+ */
+ const handleInput = useMemoizedFn(() => {
+ if (!inputRef.current || isComposingRef.current) return;
+
+ try {
+ const position = getCursorTextPosition();
+ setCursorPosition(position);
+
+ // 获取纯文本内容
+ const text = extractTextFromHTML(inputRef.current);
+
+ // 检查是否触发提及
+ const beforeCursor = text.slice(0, position);
+ const lastAtIndex = beforeCursor.lastIndexOf('@');
+
+ if (lastAtIndex !== -1) {
+ const afterAt = beforeCursor.slice(lastAtIndex + 1);
+
+ if (shouldShowMentionPopover(afterAt)) {
+ const filtered = filterOptions(options, afterAt);
+ setFilteredOptions(filtered);
+ setShowPopover(filtered.length > 0);
+ setSelectedIndex(0);
+
+ // 计算弹窗位置
+ try {
+ const selection = window.getSelection();
+ if (selection && selection.rangeCount > 0) {
+ const range = selection.getRangeAt(0);
+ const rect = range.getBoundingClientRect();
+ const inputRect = inputRef.current.getBoundingClientRect();
+
+ const newPosition = calculatePopoverPosition(rect, inputRect, popoverPlacement);
+ setPopoverPosition(newPosition);
+ }
+ } catch (e) {
+ console.warn('Failed to calculate popover position:', e);
+ }
+ } else {
+ setShowPopover(false);
+ }
+ } else {
+ setShowPopover(false);
+ }
+
+ onChange?.(text);
+ } catch (e) {
+ console.error('Error in handleInput:', e);
+ }
+ });
+
+ /**
+ * 插入提及
+ */
+ const insertMention = useMemoizedFn((option: MentionOption) => {
+ if (!inputRef.current) return;
+
+ const text = value;
+ const position = cursorPosition;
+
+ // 找到 @ 的位置
+ const beforeCursor = text.slice(0, position);
+ const lastAtIndex = beforeCursor.lastIndexOf('@');
+
+ if (lastAtIndex !== -1) {
+ const before = text.slice(0, lastAtIndex);
+ const after = text.slice(position);
+ const newValue = `${before}@{${option.value}|${option.label}}${after}`;
+
+ onChange?.(newValue);
+
+ // 更新显示并设置光标
+ setTimeout(() => {
+ const newPosition = before.length + `@{${option.value}|${option.label}}`.length;
+ setCursorTextPosition(newPosition);
+ inputRef.current?.focus();
+ }, 0);
+ }
+
+ setShowPopover(false);
+ });
+
+ /**
+ * 键盘事件处理
+ */
+ const handleKeyDown = useMemoizedFn((e: React.KeyboardEvent) => {
+ // 处理弹出菜单显示时的键盘操作
+ if (showPopover) {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setSelectedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : prev));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (filteredOptions[selectedIndex]) {
+ insertMention(filteredOptions[selectedIndex]);
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ setShowPopover(false);
+ }
+ return;
+ }
+
+ // 处理弹出菜单不显示时的键盘操作
+ if (e.key === 'Enter' && !e.shiftKey) {
+ // Enter 发送,Shift + Enter 换行
+ e.preventDefault();
+ onEnter?.(value);
+ }
+ });
+
+ return {
+ showPopover,
+ filteredOptions,
+ selectedIndex,
+ setSelectedIndex,
+ popoverPosition,
+ setPopoverPosition,
+ handleInput,
+ insertMention,
+ handleKeyDown,
+ };
+}
+
diff --git a/src/components/ui/MentionInput/usePopoverPosition.ts b/src/components/ui/MentionInput/usePopoverPosition.ts
new file mode 100644
index 0000000..198242e
--- /dev/null
+++ b/src/components/ui/MentionInput/usePopoverPosition.ts
@@ -0,0 +1,128 @@
+import { RefObject, useEffect } from 'react';
+import { PopoverPosition } from './types';
+
+interface UsePopoverPositionProps {
+ showPopover: boolean;
+ popoverRef: RefObject;
+ inputRef: RefObject;
+ popoverPosition: PopoverPosition;
+ setPopoverPosition: React.Dispatch>;
+ filteredOptionsLength: number;
+}
+
+/**
+ * 弹出框位置调整 Hook
+ * 确保弹出框始终在视口内显示
+ */
+export function usePopoverPosition({
+ showPopover,
+ popoverRef,
+ inputRef,
+ popoverPosition,
+ setPopoverPosition,
+ filteredOptionsLength,
+}: UsePopoverPositionProps) {
+ useEffect(() => {
+ if (showPopover && popoverRef.current && inputRef.current) {
+ // 使用 requestAnimationFrame 确保 DOM 已经渲染
+ requestAnimationFrame(() => {
+ if (!popoverRef.current || !inputRef.current) return;
+
+ const popover = popoverRef.current;
+ const input = inputRef.current;
+
+ // 获取弹出框的实际尺寸
+ const popoverRect = popover.getBoundingClientRect();
+ const inputRect = input.getBoundingClientRect();
+
+ // 当前弹出框的绝对位置
+ const absoluteLeft = popoverRect.left;
+ const absoluteRight = popoverRect.right;
+
+ const viewportWidth = window.innerWidth;
+ const padding = 8; // 安全边距
+
+ let adjustmentNeeded = false;
+ let newLeft = popoverPosition.left;
+
+ // 检查右边界
+ if (absoluteRight > viewportWidth - padding) {
+ newLeft = viewportWidth - inputRect.left - popoverRect.width - padding;
+ adjustmentNeeded = true;
+ }
+
+ // 检查左边界
+ if (absoluteLeft < padding) {
+ newLeft = padding - inputRect.left;
+ adjustmentNeeded = true;
+ }
+
+ // 如果位置需要调整,更新状态
+ if (adjustmentNeeded && Math.abs(newLeft - popoverPosition.left) > 1) {
+ setPopoverPosition((prev) => ({
+ ...prev,
+ left: Math.max(0, newLeft),
+ }));
+ }
+ });
+ }
+ }, [showPopover, filteredOptionsLength]);
+}
+
+/**
+ * 计算弹出框初始位置
+ */
+export function calculatePopoverPosition(
+ cursorRect: DOMRect,
+ inputRect: DOMRect,
+ placement: 'auto' | 'top' | 'bottom'
+): PopoverPosition {
+ let finalPlacement: 'top' | 'bottom' = 'bottom';
+
+ if (placement === 'top') {
+ finalPlacement = 'top';
+ } else if (placement === 'bottom') {
+ finalPlacement = 'bottom';
+ } else {
+ // auto: 自动检测,优先向上弹出(适合输入框在底部的场景)
+ const viewportHeight = window.innerHeight;
+ const spaceBelow = viewportHeight - cursorRect.bottom;
+ const spaceAbove = cursorRect.top;
+ const popoverEstimatedHeight = 240; // max-h-60 约等于 240px
+
+ // 如果下方空间不足且上方空间充足,则向上弹出
+ if (spaceBelow < popoverEstimatedHeight && spaceAbove > popoverEstimatedHeight) {
+ finalPlacement = 'top';
+ } else {
+ finalPlacement = 'bottom';
+ }
+ }
+
+ // 计算左右位置,确保不超出视口
+ const viewportWidth = window.innerWidth;
+ const popoverMinWidth = 200;
+ let leftPosition = cursorRect.left - inputRect.left;
+
+ // 检查右边界
+ const absoluteLeft = cursorRect.left;
+ if (absoluteLeft + popoverMinWidth > viewportWidth) {
+ // 超出右边界,调整到左边
+ leftPosition = viewportWidth - inputRect.left - popoverMinWidth - 8; // 8px 安全边距
+ }
+
+ // 检查左边界
+ if (absoluteLeft < 8) {
+ // 超出左边界,调整到 8px
+ leftPosition = 8 - inputRect.left;
+ }
+
+ return {
+ top:
+ finalPlacement === 'bottom'
+ ? cursorRect.bottom - inputRect.top + 4
+ : cursorRect.top - inputRect.top - 4,
+ left: leftPosition,
+ placement: finalPlacement,
+ };
+}
+
diff --git a/src/components/ui/MentionInput/utils.ts b/src/components/ui/MentionInput/utils.ts
new file mode 100644
index 0000000..49fcff4
--- /dev/null
+++ b/src/components/ui/MentionInput/utils.ts
@@ -0,0 +1,79 @@
+import { ParsedSegment } from './types';
+
+/**
+ * 解析包含提及的文本
+ * 将 @{value|label} 格式的文本解析为结构化的片段
+ */
+export function parseValue(text: string): ParsedSegment[] {
+ const segments: ParsedSegment[] = [];
+ const mentionRegex = /@\{([^|]+)\|([^}]+)\}/g;
+ let lastIndex = 0;
+ let match;
+
+ while ((match = mentionRegex.exec(text)) !== null) {
+ // 添加提及之前的文本
+ if (match.index > lastIndex) {
+ segments.push({
+ type: 'text',
+ content: text.slice(lastIndex, match.index),
+ });
+ }
+
+ // 添加提及
+ segments.push({
+ type: 'mention',
+ content: match[0],
+ value: match[1],
+ label: match[2],
+ });
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ // 添加剩余文本
+ if (lastIndex < text.length) {
+ segments.push({
+ type: 'text',
+ content: text.slice(lastIndex),
+ });
+ }
+
+ return segments;
+}
+
+/**
+ * 从 HTML 元素中提取纯文本内容
+ * 将提及元素转换为 @{value|label} 格式
+ */
+export function extractTextFromHTML(element: HTMLElement): string {
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = element.innerHTML;
+
+ // 处理提及元素
+ const mentions = tempDiv.querySelectorAll('[data-mention]');
+ mentions.forEach((mention) => {
+ const value = mention.getAttribute('data-mention');
+ const label = mention.textContent?.replace('@', '') || '';
+ mention.textContent = `@{${value}|${label}}`;
+ });
+
+ return tempDiv.textContent || '';
+}
+
+/**
+ * 检查文本中 @ 后是否应该触发提及菜单
+ */
+export function shouldShowMentionPopover(textAfterAt: string): boolean {
+ return (
+ !textAfterAt.includes(' ') &&
+ !textAfterAt.includes('\n') &&
+ !textAfterAt.match(/\{[^|]+\|[^}]+\}/)
+ );
+}
+
+/**
+ * 过滤匹配的选项
+ */
+export function filterOptions(options: T[], searchText: string): T[] {
+ return options.filter((option) => option.label.toLowerCase().includes(searchText.toLowerCase()));
+}
diff --git a/src/components/ui/infinite-scroll-list.tsx b/src/components/ui/infinite-scroll-list.tsx
index 07275bc..be6459b 100644
--- a/src/components/ui/infinite-scroll-list.tsx
+++ b/src/components/ui/infinite-scroll-list.tsx
@@ -4,33 +4,26 @@ import { cn } from '@/lib/utils';
import { useSize } from 'ahooks';
/**
- * 懒渲染项组件
- * 使用 IntersectionObserver 监控元素可见性,只渲染可见的内容
+ * 虚拟渲染项组件
+ * 只渲染在视口内或附近的内容,离开视口时卸载
*/
-interface LazyItemProps {
+interface VirtualItemProps {
children: ReactNode;
rootMargin?: string;
placeholder?: ReactNode;
}
-function LazyItem({ children, rootMargin = '300px', placeholder }: LazyItemProps) {
+function VirtualItem({ children, rootMargin = '300px', placeholder }: VirtualItemProps) {
const ref = useRef(null);
- const [hasBeenVisible, setHasBeenVisible] = useState(false);
+ const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
- // 如果已经渲染过,不需要再观察
- if (hasBeenVisible) return;
-
const observer = new IntersectionObserver(
([entry]) => {
- if (entry.isIntersecting) {
- setHasBeenVisible(true);
- // ✅ 渲染后立即断开观察,释放资源
- observer.disconnect();
- }
+ setIsVisible(entry.isIntersecting);
},
{
rootMargin,
@@ -43,13 +36,9 @@ function LazyItem({ children, rootMargin = '300px', placeholder }: LazyItemProps
return () => {
observer.disconnect();
};
- }, [rootMargin, hasBeenVisible]);
+ }, [rootMargin]);
- return (
-
- {hasBeenVisible ? children : placeholder ||
}
-
- );
+ return {isVisible ? children : placeholder}
;
}
interface InfiniteScrollListProps {
@@ -131,15 +120,23 @@ interface InfiniteScrollListProps {
*/
enabled?: boolean;
/**
- * 是否启用懒渲染(只渲染可见区域的内容)
+ * 是否启用虚拟渲染(离开视口时卸载内容,默认开启)
*/
- enableLazyRender?: boolean;
+ enableVirtualRender?: boolean;
/**
- * 懒渲染的根边距(提前多少px开始渲染)
+ * 虚拟渲染的根边距(提前多少px开始渲染,默认300px)
*/
- lazyRenderMargin?: string;
+ virtualRenderMargin?: string;
+ /**
+ * 虚拟渲染时的占位符组件
+ */
+ VirtualPlaceholder?: React.ComponentType;
}
+const defaultVirtualPlaceholder = () => (
+
+);
+
/**
* 通用的无限滚动列表组件
* 支持网格布局、骨架屏、错误状态等功能
@@ -162,8 +159,9 @@ export function InfiniteScrollList({
onRetry,
threshold = 200,
enabled = true,
- enableLazyRender = false,
- lazyRenderMargin = '300px',
+ enableVirtualRender = true,
+ virtualRenderMargin = '300px',
+ VirtualPlaceholder = defaultVirtualPlaceholder,
}: InfiniteScrollListProps) {
const ref = useRef(null);
const size = useSize(ref);
@@ -262,17 +260,15 @@ export function InfiniteScrollList({
const key = getItemKey(item, index);
const content = renderItem(item, index);
- if (enableLazyRender) {
+ if (enableVirtualRender) {
return (
-
- }
+ rootMargin={virtualRenderMargin}
+ placeholder={}
>
{content}
-
+
);
}