feat: 添加提及组件

This commit is contained in:
liuyonghe0111 2025-12-29 17:18:11 +08:00
parent 5cb01064ef
commit 47f86102b7
12 changed files with 1106 additions and 34 deletions

View File

@ -23,8 +23,6 @@ const Character = () => {
<div className="mt-4 sm:mt-8"> <div className="mt-4 sm:mt-8">
<InfiniteScrollList<any> <InfiniteScrollList<any>
items={dataSource} items={dataSource}
enableLazyRender
lazyRenderMargin="500px"
columns={(width) => { columns={(width) => {
const cardWidth = width > 1200 ? 256 : width > 588 ? 200 : width > 375 ? 170 : 150; const cardWidth = width > 1200 ? 256 : width > 588 ? 200 : width > 375 ? 170 : 150;
return Math.floor(width / cardWidth); return Math.floor(width / cardWidth);

View File

@ -6,6 +6,7 @@ import { fetchCharacters } from '@/services/editor';
import { useHomeStore } from '../../store'; import { useHomeStore } from '../../store';
import { useEffect } from 'react'; import { useEffect } from 'react';
import StoryContent from '@/components/features/StoryContent'; import StoryContent from '@/components/features/StoryContent';
import MentionInputExample from '@/components/ui/MentionInput/example';
const Story = () => { const Story = () => {
const characterParams = useHomeStore((state) => state.characterParams); const characterParams = useHomeStore((state) => state.characterParams);
@ -23,6 +24,7 @@ const Story = () => {
return ( return (
<div className="mt-4 sm:mt-8"> <div className="mt-4 sm:mt-8">
<StoryContent /> <StoryContent />
<MentionInputExample />
{/* <InfiniteScrollList<any> {/* <InfiniteScrollList<any>
items={dataSource} items={dataSource}
enableLazyRender enableLazyRender

View File

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

View File

@ -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` 避免不必要的重渲染

View File

@ -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> autotopbottom
</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>
);
}

View File

@ -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';

View File

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

View File

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

View File

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

View File

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

View File

@ -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()));
}

View File

@ -4,33 +4,26 @@ import { cn } from '@/lib/utils';
import { useSize } from 'ahooks'; import { useSize } from 'ahooks';
/** /**
* *
* 使 IntersectionObserver *
*/ */
interface LazyItemProps { interface VirtualItemProps {
children: ReactNode; children: ReactNode;
rootMargin?: string; rootMargin?: string;
placeholder?: ReactNode; placeholder?: ReactNode;
} }
function LazyItem({ children, rootMargin = '300px', placeholder }: LazyItemProps) { function VirtualItem({ children, rootMargin = '300px', placeholder }: VirtualItemProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [hasBeenVisible, setHasBeenVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
useEffect(() => { useEffect(() => {
const element = ref.current; const element = ref.current;
if (!element) return; if (!element) return;
// 如果已经渲染过,不需要再观察
if (hasBeenVisible) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (entry.isIntersecting) { setIsVisible(entry.isIntersecting);
setHasBeenVisible(true);
// ✅ 渲染后立即断开观察,释放资源
observer.disconnect();
}
}, },
{ {
rootMargin, rootMargin,
@ -43,13 +36,9 @@ function LazyItem({ children, rootMargin = '300px', placeholder }: LazyItemProps
return () => { return () => {
observer.disconnect(); observer.disconnect();
}; };
}, [rootMargin, hasBeenVisible]); }, [rootMargin]);
return ( return <div ref={ref}>{isVisible ? children : placeholder}</div>;
<div ref={ref}>
{hasBeenVisible ? children : placeholder || <div className="aspect-[3/4]" />}
</div>
);
} }
interface InfiniteScrollListProps<T> { interface InfiniteScrollListProps<T> {
@ -131,15 +120,23 @@ interface InfiniteScrollListProps<T> {
*/ */
enabled?: boolean; 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, onRetry,
threshold = 200, threshold = 200,
enabled = true, enabled = true,
enableLazyRender = false, enableVirtualRender = true,
lazyRenderMargin = '300px', virtualRenderMargin = '300px',
VirtualPlaceholder = defaultVirtualPlaceholder,
}: InfiniteScrollListProps<T>) { }: InfiniteScrollListProps<T>) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref); const size = useSize(ref);
@ -262,17 +260,15 @@ export function InfiniteScrollList<T>({
const key = getItemKey(item, index); const key = getItemKey(item, index);
const content = renderItem(item, index); const content = renderItem(item, index);
if (enableLazyRender) { if (enableVirtualRender) {
return ( return (
<LazyItem <VirtualItem
key={key} key={key}
rootMargin={lazyRenderMargin} rootMargin={virtualRenderMargin}
placeholder={ placeholder={<VirtualPlaceholder />}
<div className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl" />
}
> >
{content} {content}
</LazyItem> </VirtualItem>
); );
} }