feat: 添加提及组件
This commit is contained in:
parent
5cb01064ef
commit
47f86102b7
|
|
@ -23,8 +23,6 @@ const Character = () => {
|
|||
<div className="mt-4 sm:mt-8">
|
||||
<InfiniteScrollList<any>
|
||||
items={dataSource}
|
||||
enableLazyRender
|
||||
lazyRenderMargin="500px"
|
||||
columns={(width) => {
|
||||
const cardWidth = width > 1200 ? 256 : width > 588 ? 200 : width > 375 ? 170 : 150;
|
||||
return Math.floor(width / cardWidth);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="mt-4 sm:mt-8">
|
||||
<StoryContent />
|
||||
<MentionInputExample />
|
||||
{/* <InfiniteScrollList<any>
|
||||
items={dataSource}
|
||||
enableLazyRender
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>;
|
||||
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<MentionPopoverProps> = ({
|
||||
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 (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={cn(
|
||||
'absolute z-50 max-h-60 overflow-auto rounded-md border border-gray-200 bg-white shadow-lg',
|
||||
popoverClassName
|
||||
)}
|
||||
style={{
|
||||
[popoverPosition.placement === 'bottom' ? 'top' : 'bottom']:
|
||||
popoverPosition.placement === 'bottom'
|
||||
? popoverPosition.top
|
||||
: `calc(100% - ${popoverPosition.top}px)`,
|
||||
left: popoverPosition.left,
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
// 使用自定义渲染器或默认渲染
|
||||
const content = popoverItemRender ? popoverItemRender(option, isSelected) : `@${option.label}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'cursor-pointer px-4 py-2 text-sm hover:bg-gray-100',
|
||||
isSelected && 'bg-blue-50 text-blue-600',
|
||||
popoverItemClassName
|
||||
)}
|
||||
onClick={() => insertMention(option)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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 (
|
||||
<MentionInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onEnter={(val) => console.log('Send:', val)}
|
||||
options={options}
|
||||
popoverPlacement="auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **关注点分离**:每个模块专注于单一职责
|
||||
2. **可测试性**:独立的 hooks 和工具函数易于单元测试
|
||||
3. **可复用性**:hooks 可在其他组件中复用
|
||||
4. **类型安全**:完整的 TypeScript 类型定义
|
||||
5. **性能优化**:使用 `useMemoizedFn` 避免不必要的重渲染
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="p-8 max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">MentionInput 组件示例</h1>
|
||||
<p className="text-gray-600 mb-4">
|
||||
在输入框中输入 @ 符号,然后使用键盘↑↓选择用户,按回车确认选择。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 基础用法 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">基础用法</h2>
|
||||
<MentionInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={mentionOptions}
|
||||
placeholder="输入 @ 来提及用户..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enter 发送 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">Enter 发送消息</h2>
|
||||
<p className="text-sm text-gray-600 mb-2">按 Enter 发送消息,按 Shift + Enter 换行</p>
|
||||
<MentionInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onEnter={handleEnter}
|
||||
options={mentionOptions}
|
||||
placeholder="输入内容,按 Enter 发送..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 弹出位置 - 向上 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">弹出位置 - 向上弹出</h2>
|
||||
<MentionInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={mentionOptions}
|
||||
placeholder="输入 @ 时菜单向上弹出..."
|
||||
popoverPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 弹出位置 - 自动检测 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">弹出位置 - 自动检测(默认)</h2>
|
||||
<MentionInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={mentionOptions}
|
||||
placeholder="自动检测上下空间,智能选择弹出方向..."
|
||||
popoverPlacement="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 自定义弹出项样式 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">自定义弹出项样式</h2>
|
||||
<MentionInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={mentionOptions}
|
||||
placeholder="自定义弹出项的类名..."
|
||||
popoverItemClassName="font-bold text-base py-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 自定义弹出项渲染 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">自定义弹出项渲染</h2>
|
||||
<MentionInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={mentionOptions}
|
||||
placeholder="自定义每一项的渲染样式..."
|
||||
popoverPlacement="top"
|
||||
popoverItemRender={(option, isSelected) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-sm',
|
||||
isSelected ? 'bg-blue-500' : 'bg-gray-400'
|
||||
)}
|
||||
>
|
||||
{option.label.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold">@{option.label}</div>
|
||||
<div className="text-xs text-gray-500">ID: {option.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 显示当前值 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">当前输入值</h2>
|
||||
<div className="p-4 bg-gray-100 rounded-md">
|
||||
<pre className="text-sm whitespace-pre-wrap break-words">{value || '(空)'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="border-t pt-6">
|
||||
<h2 className="text-lg font-semibold mb-2">功能说明</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-gray-700">
|
||||
<li>输入 @ 符号触发用户选择菜单</li>
|
||||
<li>继续输入可以过滤用户列表</li>
|
||||
<li>使用 ↑↓ 方向键选择用户</li>
|
||||
<li>在提及菜单显示时:按 Enter 确认选择,按 Esc 关闭菜单</li>
|
||||
<li>
|
||||
<strong>Enter 键行为:</strong>在提及菜单不显示时,按 Enter 发送消息(调用 onEnter),按
|
||||
Shift + Enter 换行
|
||||
</li>
|
||||
<li>选中的用户以特殊颜色高亮显示</li>
|
||||
<li>最终输出格式:@{'{value|label}'}</li>
|
||||
<li>
|
||||
<strong>弹出位置:</strong>支持 auto(自动检测)、top(向上)、bottom(向下)
|
||||
</li>
|
||||
<li>
|
||||
<strong>自定义样式:</strong>可通过 popoverItemClassName 自定义弹出项样式
|
||||
</li>
|
||||
<li>
|
||||
<strong>自定义渲染:</strong>可通过 popoverItemRender 完全自定义弹出项内容
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 测试案例 */}
|
||||
<div className="border-t pt-6">
|
||||
<h2 className="text-lg font-semibold mb-2">测试案例</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>输入:</strong>"@元芳,你怎么看"
|
||||
</p>
|
||||
<p>
|
||||
<strong>预期输出:</strong>"@{'{yuanfang|元芳}'},你怎么看"
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setValue('@{yuanfang|元芳},你怎么看')}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
加载测试数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<MentionInputProps> = ({
|
||||
value = '',
|
||||
onChange,
|
||||
onEnter,
|
||||
options,
|
||||
placeholder = '输入 @ 来提及...',
|
||||
className,
|
||||
mentionClassName,
|
||||
popoverClassName,
|
||||
popoverItemClassName,
|
||||
popoverPlacement = 'auto',
|
||||
popoverItemRender,
|
||||
disabled = false,
|
||||
maxHeight = '200px',
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={inputRef}
|
||||
contentEditable={!disabled}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => {
|
||||
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 && (
|
||||
<MentionPopover
|
||||
popoverRef={popoverRef}
|
||||
filteredOptions={filteredOptions}
|
||||
selectedIndex={selectedIndex}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
popoverPosition={popoverPosition}
|
||||
insertMention={insertMention}
|
||||
popoverClassName={popoverClassName}
|
||||
popoverItemClassName={popoverItemClassName}
|
||||
popoverItemRender={popoverItemRender}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MentionInput;
|
||||
export * from './types';
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { RefObject } from 'react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
/**
|
||||
* 光标位置管理 Hook
|
||||
* 处理 contentEditable 元素中的光标位置获取和设置
|
||||
*/
|
||||
export function useCursorPosition(inputRef: RefObject<HTMLDivElement | null>) {
|
||||
/**
|
||||
* 获取光标位置对应的文本位置
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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<HTMLDivElement | null>;
|
||||
isComposingRef: RefObject<boolean>;
|
||||
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<MentionOption[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [popoverPosition, setPopoverPosition] = useState<PopoverPosition>({
|
||||
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<HTMLDivElement>) => {
|
||||
// 处理弹出菜单显示时的键盘操作
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { RefObject, useEffect } from 'react';
|
||||
import { PopoverPosition } from './types';
|
||||
|
||||
interface UsePopoverPositionProps {
|
||||
showPopover: boolean;
|
||||
popoverRef: RefObject<HTMLDivElement | null>;
|
||||
inputRef: RefObject<HTMLDivElement | null>;
|
||||
popoverPosition: PopoverPosition;
|
||||
setPopoverPosition: React.Dispatch<React.SetStateAction<PopoverPosition>>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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<T extends { label: string }>(options: T[], searchText: string): T[] {
|
||||
return options.filter((option) => option.label.toLowerCase().includes(searchText.toLowerCase()));
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div ref={ref}>
|
||||
{hasBeenVisible ? children : placeholder || <div className="aspect-[3/4]" />}
|
||||
</div>
|
||||
);
|
||||
return <div ref={ref}>{isVisible ? children : placeholder}</div>;
|
||||
}
|
||||
|
||||
interface InfiniteScrollListProps<T> {
|
||||
|
|
@ -131,15 +120,23 @@ interface InfiniteScrollListProps<T> {
|
|||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* 是否启用懒渲染(只渲染可见区域的内容)
|
||||
* 是否启用虚拟渲染(离开视口时卸载内容,默认开启)
|
||||
*/
|
||||
enableLazyRender?: boolean;
|
||||
enableVirtualRender?: boolean;
|
||||
/**
|
||||
* 懒渲染的根边距(提前多少px开始渲染)
|
||||
* 虚拟渲染的根边距(提前多少px开始渲染,默认300px)
|
||||
*/
|
||||
lazyRenderMargin?: string;
|
||||
virtualRenderMargin?: string;
|
||||
/**
|
||||
* 虚拟渲染时的占位符组件
|
||||
*/
|
||||
VirtualPlaceholder?: React.ComponentType;
|
||||
}
|
||||
|
||||
const defaultVirtualPlaceholder = () => (
|
||||
<div className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl" />
|
||||
);
|
||||
|
||||
/**
|
||||
* 通用的无限滚动列表组件
|
||||
* 支持网格布局、骨架屏、错误状态等功能
|
||||
|
|
@ -162,8 +159,9 @@ export function InfiniteScrollList<T>({
|
|||
onRetry,
|
||||
threshold = 200,
|
||||
enabled = true,
|
||||
enableLazyRender = false,
|
||||
lazyRenderMargin = '300px',
|
||||
enableVirtualRender = true,
|
||||
virtualRenderMargin = '300px',
|
||||
VirtualPlaceholder = defaultVirtualPlaceholder,
|
||||
}: InfiniteScrollListProps<T>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const size = useSize(ref);
|
||||
|
|
@ -262,17 +260,15 @@ export function InfiniteScrollList<T>({
|
|||
const key = getItemKey(item, index);
|
||||
const content = renderItem(item, index);
|
||||
|
||||
if (enableLazyRender) {
|
||||
if (enableVirtualRender) {
|
||||
return (
|
||||
<LazyItem
|
||||
<VirtualItem
|
||||
key={key}
|
||||
rootMargin={lazyRenderMargin}
|
||||
placeholder={
|
||||
<div className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl" />
|
||||
}
|
||||
rootMargin={virtualRenderMargin}
|
||||
placeholder={<VirtualPlaceholder />}
|
||||
>
|
||||
{content}
|
||||
</LazyItem>
|
||||
</VirtualItem>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue