From 47f86102b794b55394ec2e2753f400dcd461ac62 Mon Sep 17 00:00:00 2001 From: liuyonghe0111 <1763195287@qq.com> Date: Mon, 29 Dec 2025 17:18:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=8F=90=E5=8F=8A?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/components/Character/index.tsx | 2 - .../(main)/home/components/Story/index.tsx | 2 + .../ui/MentionInput/MentionPopover.tsx | 91 +++++++++ src/components/ui/MentionInput/README.md | 89 +++++++++ src/components/ui/MentionInput/example.tsx | 185 ++++++++++++++++++ src/components/ui/MentionInput/index.tsx | 172 ++++++++++++++++ src/components/ui/MentionInput/types.ts | 34 ++++ .../ui/MentionInput/useCursorPosition.ts | 126 ++++++++++++ .../ui/MentionInput/useMentionInput.ts | 172 ++++++++++++++++ .../ui/MentionInput/usePopoverPosition.ts | 128 ++++++++++++ src/components/ui/MentionInput/utils.ts | 79 ++++++++ src/components/ui/infinite-scroll-list.tsx | 60 +++--- 12 files changed, 1106 insertions(+), 34 deletions(-) create mode 100644 src/components/ui/MentionInput/MentionPopover.tsx create mode 100644 src/components/ui/MentionInput/README.md create mode 100644 src/components/ui/MentionInput/example.tsx create mode 100644 src/components/ui/MentionInput/index.tsx create mode 100644 src/components/ui/MentionInput/types.ts create mode 100644 src/components/ui/MentionInput/useCursorPosition.ts create mode 100644 src/components/ui/MentionInput/useMentionInput.ts create mode 100644 src/components/ui/MentionInput/usePopoverPosition.ts create mode 100644 src/components/ui/MentionInput/utils.ts diff --git a/src/app/(main)/home/components/Character/index.tsx b/src/app/(main)/home/components/Character/index.tsx index 71f8bea..4ff715f 100644 --- a/src/app/(main)/home/components/Character/index.tsx +++ b/src/app/(main)/home/components/Character/index.tsx @@ -23,8 +23,6 @@ const Character = () => {
items={dataSource} - enableLazyRender - lazyRenderMargin="500px" columns={(width) => { const cardWidth = width > 1200 ? 256 : width > 588 ? 200 : width > 375 ? 170 : 150; return Math.floor(width / cardWidth); diff --git a/src/app/(main)/home/components/Story/index.tsx b/src/app/(main)/home/components/Story/index.tsx index 89db513..3e23995 100644 --- a/src/app/(main)/home/components/Story/index.tsx +++ b/src/app/(main)/home/components/Story/index.tsx @@ -6,6 +6,7 @@ import { fetchCharacters } from '@/services/editor'; import { useHomeStore } from '../../store'; import { useEffect } from 'react'; import StoryContent from '@/components/features/StoryContent'; +import MentionInputExample from '@/components/ui/MentionInput/example'; const Story = () => { const characterParams = useHomeStore((state) => state.characterParams); @@ -23,6 +24,7 @@ const Story = () => { return (
+ {/* items={dataSource} enableLazyRender diff --git a/src/components/ui/MentionInput/MentionPopover.tsx b/src/components/ui/MentionInput/MentionPopover.tsx new file mode 100644 index 0000000..2e3bc59 --- /dev/null +++ b/src/components/ui/MentionInput/MentionPopover.tsx @@ -0,0 +1,91 @@ +'use client'; + +import React, { RefObject, useEffect } from 'react'; +import { cn } from '@/lib/utils'; +import { MentionOption, PopoverPosition } from './types'; + +interface MentionPopoverProps { + popoverRef: RefObject; + filteredOptions: MentionOption[]; + selectedIndex: number; + setSelectedIndex: (index: number) => void; + popoverPosition: PopoverPosition; + insertMention: (option: MentionOption) => void; + popoverClassName?: string; + popoverItemClassName?: string; + popoverItemRender?: (option: MentionOption, isSelected: boolean) => React.ReactNode; +} + +/** + * 提及弹出框组件 + */ +export const MentionPopover: React.FC = ({ + popoverRef, + filteredOptions, + selectedIndex, + setSelectedIndex, + popoverPosition, + insertMention, + popoverClassName, + popoverItemClassName, + popoverItemRender, +}) => { + // 滚动选中项到可见区域 + useEffect(() => { + if (popoverRef.current) { + const selectedElement = popoverRef.current.querySelector(`[data-index="${selectedIndex}"]`); + if (selectedElement) { + selectedElement.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); + } + } + }, [selectedIndex, popoverRef]); + + if (filteredOptions.length === 0) { + return null; + } + + return ( +
+ {filteredOptions.map((option, index) => { + const isSelected = index === selectedIndex; + + // 使用自定义渲染器或默认渲染 + const content = popoverItemRender ? popoverItemRender(option, isSelected) : `@${option.label}`; + + return ( +
insertMention(option)} + onMouseEnter={() => setSelectedIndex(index)} + > + {content} +
+ ); + })} +
+ ); +}; + diff --git a/src/components/ui/MentionInput/README.md b/src/components/ui/MentionInput/README.md new file mode 100644 index 0000000..860bbbb --- /dev/null +++ b/src/components/ui/MentionInput/README.md @@ -0,0 +1,89 @@ +# MentionInput 组件 + +支持 @ 提及功能的富文本输入框组件。 + +## 文件结构 + +``` +MentionInput/ +├── index.tsx # 主组件入口 +├── types.ts # TypeScript 类型定义 +├── utils.ts # 工具函数(文本解析等) +├── useCursorPosition.ts # 光标位置管理 Hook +├── useMentionInput.ts # 核心逻辑 Hook(输入处理、提及插入、键盘事件) +├── usePopoverPosition.ts # 弹出框位置计算和调整 Hook +├── MentionPopover.tsx # 弹出框组件 +└── README.md # 本文档 +``` + +## 模块说明 + +### types.ts +- `MentionOption`: 提及选项类型 +- `MentionInputProps`: 组件 Props 类型 +- `ParsedSegment`: 文本解析片段类型 +- `PopoverPosition`: 弹出框位置类型 + +### utils.ts +提供工具函数: +- `parseValue()`: 解析 `@{value|label}` 格式文本 +- `extractTextFromHTML()`: 从 HTML 提取纯文本 +- `shouldShowMentionPopover()`: 判断是否显示提及菜单 +- `filterOptions()`: 过滤匹配的选项 + +### useCursorPosition.ts +管理 contentEditable 元素中的光标位置: +- `getCursorTextPosition()`: 获取光标位置 +- `setCursorTextPosition()`: 设置光标位置 + +### useMentionInput.ts +核心业务逻辑: +- 处理输入事件 +- 插入提及 +- 键盘事件处理 +- 管理弹出框状态 + +### usePopoverPosition.ts +弹出框位置管理: +- `calculatePopoverPosition()`: 计算初始位置 +- `usePopoverPosition()`: 动态调整位置,确保在视口内 + +### MentionPopover.tsx +弹出框 UI 组件,负责渲染提及选项列表 + +### index.tsx +主组件,组合所有 hooks 和子组件 + +## 使用示例 + +```tsx +import MentionInput, { MentionOption } from './MentionInput'; + +const options: MentionOption[] = [ + { label: '元芳', value: 'yuanfang' }, + { label: '张三', value: 'zhangsan' }, +]; + +function MyComponent() { + const [value, setValue] = useState(''); + + return ( + console.log('Send:', val)} + options={options} + popoverPlacement="auto" + /> + ); +} +``` + +## 设计原则 + +1. **关注点分离**:每个模块专注于单一职责 +2. **可测试性**:独立的 hooks 和工具函数易于单元测试 +3. **可复用性**:hooks 可在其他组件中复用 +4. **类型安全**:完整的 TypeScript 类型定义 +5. **性能优化**:使用 `useMemoizedFn` 避免不必要的重渲染 + diff --git a/src/components/ui/MentionInput/example.tsx b/src/components/ui/MentionInput/example.tsx new file mode 100644 index 0000000..56bedc1 --- /dev/null +++ b/src/components/ui/MentionInput/example.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useState } from 'react'; +import MentionInput, { MentionOption } from '@/components/ui/MentionInput/index'; +import { cn } from '@/lib/utils'; + +/** + * MentionInput 使用示例 + */ +export default function MentionInputExample() { + const [value, setValue] = useState(''); + + // 可选择的用户列表 + const mentionOptions: MentionOption[] = [ + { label: '元芳', value: 'yuanfang' }, + { label: '张三', value: 'zhangsan' }, + { label: '李四', value: 'lisi' }, + { label: '王五', value: 'wangwu' }, + { label: '赵六', value: 'liuliu' }, + ]; + + const handleChange = (newValue: string) => { + console.log('输入值:', newValue); + setValue(newValue); + }; + + const handleEnter = (value: string) => { + console.log('按下 Enter,发送内容:', value); + alert(`发送内容:${value}`); + // 发送后清空输入框 + setValue(''); + }; + + return ( +
+
+

MentionInput 组件示例

+

+ 在输入框中输入 @ 符号,然后使用键盘↑↓选择用户,按回车确认选择。 +

+
+ + {/* 基础用法 */} +
+

基础用法

+ +
+ + {/* Enter 发送 */} +
+

Enter 发送消息

+

按 Enter 发送消息,按 Shift + Enter 换行

+ +
+ + {/* 弹出位置 - 向上 */} +
+

弹出位置 - 向上弹出

+ +
+ + {/* 弹出位置 - 自动检测 */} +
+

弹出位置 - 自动检测(默认)

+ +
+ + {/* 自定义弹出项样式 */} +
+

自定义弹出项样式

+ +
+ + {/* 自定义弹出项渲染 */} +
+

自定义弹出项渲染

+ ( +
+
+ {option.label.charAt(0)} +
+
+
@{option.label}
+
ID: {option.value}
+
+
+ )} + /> +
+ + {/* 显示当前值 */} +
+

当前输入值

+
+
{value || '(空)'}
+
+
+ + {/* 使用说明 */} +
+

功能说明

+
    +
  • 输入 @ 符号触发用户选择菜单
  • +
  • 继续输入可以过滤用户列表
  • +
  • 使用 ↑↓ 方向键选择用户
  • +
  • 在提及菜单显示时:按 Enter 确认选择,按 Esc 关闭菜单
  • +
  • + Enter 键行为:在提及菜单不显示时,按 Enter 发送消息(调用 onEnter),按 + Shift + Enter 换行 +
  • +
  • 选中的用户以特殊颜色高亮显示
  • +
  • 最终输出格式:@{'{value|label}'}
  • +
  • + 弹出位置:支持 auto(自动检测)、top(向上)、bottom(向下) +
  • +
  • + 自定义样式:可通过 popoverItemClassName 自定义弹出项样式 +
  • +
  • + 自定义渲染:可通过 popoverItemRender 完全自定义弹出项内容 +
  • +
+
+ + {/* 测试案例 */} +
+

测试案例

+
+

+ 输入:"@元芳,你怎么看" +

+

+ 预期输出:"@{'{yuanfang|元芳}'},你怎么看" +

+ +
+
+
+ ); +} diff --git a/src/components/ui/MentionInput/index.tsx b/src/components/ui/MentionInput/index.tsx new file mode 100644 index 0000000..336777b --- /dev/null +++ b/src/components/ui/MentionInput/index.tsx @@ -0,0 +1,172 @@ +'use client'; + +import React, { useRef, useEffect } from 'react'; +import { cn } from '@/lib/utils'; +import { MentionInputProps } from './types'; +import { parseValue } from './utils'; +import { useCursorPosition } from './useCursorPosition'; +import { useMentionInput } from './useMentionInput'; +import { usePopoverPosition } from './usePopoverPosition'; +import { MentionPopover } from './MentionPopover'; + +/** + * MentionInput 组件 + * 支持 @ 提及功能的富文本输入框 + */ +const MentionInput: React.FC = ({ + value = '', + onChange, + onEnter, + options, + placeholder = '输入 @ 来提及...', + className, + mentionClassName, + popoverClassName, + popoverItemClassName, + popoverPlacement = 'auto', + popoverItemRender, + disabled = false, + maxHeight = '200px', +}) => { + const inputRef = useRef(null); + const popoverRef = useRef(null); + const isComposingRef = useRef(false); + + // 光标位置管理 + const { getCursorTextPosition, setCursorTextPosition } = useCursorPosition(inputRef); + + // 提及输入核心逻辑 + const { + showPopover, + filteredOptions, + selectedIndex, + setSelectedIndex, + popoverPosition, + setPopoverPosition, + handleInput, + insertMention, + handleKeyDown, + } = useMentionInput({ + value, + options, + onChange, + onEnter, + popoverPlacement, + inputRef, + isComposingRef, + getCursorTextPosition, + setCursorTextPosition, + }); + + // 弹出框位置调整 + usePopoverPosition({ + showPopover, + popoverRef, + inputRef, + popoverPosition, + setPopoverPosition, + filteredOptionsLength: filteredOptions.length, + }); + + // 更新显示内容 + useEffect(() => { + if (!inputRef.current) return; + + // 获取当前光标位置(在清空之前) + let currentPosition = 0; + try { + const selection = window.getSelection(); + if ( + selection && + selection.rangeCount > 0 && + inputRef.current.contains(selection.anchorNode) + ) { + currentPosition = getCursorTextPosition(); + } + } catch (e) { + console.warn('Failed to get cursor position:', e); + } + + // 清空并重建内容 + inputRef.current.innerHTML = ''; + + if (!value) { + // 空值时不添加任何内容,让 CSS placeholder 处理 + return; + } + + const segments = parseValue(value); + segments.forEach((segment) => { + if (segment.type === 'mention') { + const span = document.createElement('span'); + span.className = cn( + 'inline-block px-1 rounded bg-blue-100 text-blue-600 font-medium', + mentionClassName + ); + span.setAttribute('data-mention', segment.value || ''); + span.contentEditable = 'false'; + span.textContent = `@${segment.label}`; + inputRef.current?.appendChild(span); + } else { + const textNode = document.createTextNode(segment.content); + inputRef.current?.appendChild(textNode); + } + }); + + // 恢复光标位置 + if (currentPosition > 0) { + try { + // 使用 requestAnimationFrame 确保 DOM 更新完成 + requestAnimationFrame(() => { + setCursorTextPosition(currentPosition); + }); + } catch (e) { + console.warn('Failed to set cursor position:', e); + } + } + }, [value, mentionClassName, getCursorTextPosition, setCursorTextPosition]); + + return ( +
+
{ + 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} - + ); }