【HarmonyOS】RN of HarmonyOS实战项目:TextInput富文本编辑器实现

🌸你好呀!我是 lbb小魔仙
🌟 感谢陪伴~ 小白博主在线求友
🌿 跟着小白学Linux/Java/Python
📖 专栏汇总:
《Linux》专栏 | 《Java》专栏 | 《Python》专栏

- [【HarmonyOS】RN of HarmonyOS实战项目:TextInput富文本编辑器实现](#【HarmonyOS】RN of HarmonyOS实战项目:TextInput富文本编辑器实现)
-
- 一、项目背景
- 二、技术架构
-
- [2.1 架构设计](#2.1 架构设计)
- [2.2 数据结构设计](#2.2 数据结构设计)
- 三、核心实现代码
-
- [3.1 富文本编辑器核心组件](#3.1 富文本编辑器核心组件)
- [3.2 @提及功能实现](#3.2 @提及功能实现)
- [3.3 话题标签检测器](#3.3 话题标签检测器)
- [3.4 使用示例](#3.4 使用示例)
- 四、HarmonyOS平台优化
-
- [4.1 键盘避让优化](#4.1 键盘避让优化)
- [4.2 性能优化策略](#4.2 性能优化策略)
- 五、总结
项目概述:本文详细介绍在HarmonyOS平台上使用React Native实现功能完整的富文本编辑器,涵盖文本格式化、表情插入、@提及、话题标签等社交应用常见功能,提供从架构设计到工程实践的完整解决方案。
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
一、项目背景
在现代社交应用中,富文本编辑器是核心功能之一。用户需要支持多种格式的文本编辑体验,包括粗体、斜体、颜色、表情、@用户、话题标签等。在HarmonyOS平台上实现这些功能需要考虑:
- 文本格式化:支持多种内联样式(粗体、斜体、颜色、字体大小等)
- 特殊内容插入:表情符号、@提及、话题标签、链接等
- 光标管理:在格式化内容间正确导航和选择
- 性能优化:大量文本时的流畅编辑体验
- 平台适配:HarmonyOS特有的输入法和键盘行为
二、技术架构
2.1 架构设计
用户输入层
输入处理器
文本解析引擎
格式标记器
渲染引擎
React Native Text组件
命令队列
历史记录管理器
撤销/重做功能
2.2 数据结构设计
typescript
/**
* 富文本片段类型定义
*/
type TextFragmentType =
| 'text' // 普通文本
| 'bold' // 粗体
| 'italic' // 斜体
| 'code' // 代码
| 'mention' // @提及
| 'hashtag' // 话题标签
| 'link' // 链接
| 'emoji'; // 表情
/**
* 富文本片段接口
*/
interface TextFragment {
type: TextFragmentType;
content: string;
styles?: TextStyle;
metadata?: {
userId?: string; // 用于@提及
url?: string; // 用于链接
color?: string; // 自定义颜色
size?: number; // 字体大小
};
}
/**
* 富文本编辑器状态
*/
interface RichTextState {
fragments: TextFragment[];
selection: {
start: number;
end: number;
};
history: {
past: TextFragment[][];
present: TextFragment[];
future: TextFragment[][];
};
}
三、核心实现代码
3.1 富文本编辑器核心组件
typescript
/**
* 富文本编辑器组件
* 支持多种格式化和特殊内容插入
*
* @platform HarmonyOS 2.0+
* @react-native 0.72+
*/
import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import {
View,
Text,
TextInput,
StyleSheet,
TouchableOpacity,
ScrollView,
KeyboardAvoidingView,
Platform,
LayoutChangeEvent,
} from 'react-native';
interface RichTextEditorProps {
placeholder?: string;
maxLength?: number;
minHeight?: number;
maxHeight?: number;
onTextChange?: (fragments: TextFragment[]) => void;
onSubmit?: (fragments: TextFragment[]) => void;
enableMention?: boolean;
enableHashtag?: boolean;
enableEmoji?: boolean;
}
export const RichTextEditor: React.FC<RichTextEditorProps> = ({
placeholder = '输入内容...',
maxLength = 5000,
minHeight = 100,
maxHeight = 200,
onTextChange,
onSubmit,
enableMention = true,
enableHashtag = true,
enableEmoji = true,
}) => {
const [fragments, setFragments] = useState<TextFragment[]>([
{ type: 'text', content: '' }
]);
const [currentStyle, setCurrentStyle] = useState<Partial<TextStyle>>({});
const [height, setHeight] = useState(minHeight);
const [isFormatting, setIsFormatting] = useState(false);
const inputRef = useRef<TextInput>(null);
const selectionRef = useRef({ start: 0, end: 0 });
/**
* 计算当前字符位置(考虑格式化标签)
*/
const getCursorPosition = useCallback((
frags: TextFragment[],
position: number
): { fragmentIndex: number; offset: number } => {
let currentPos = 0;
for (let i = 0; i < frags.length; i++) {
const fragment = frags[i];
const fragmentLength = fragment.content.length;
if (currentPos + fragmentLength >= position) {
return { fragmentIndex: i, offset: position - currentPos };
}
currentPos += fragmentLength;
}
return { fragmentIndex: frags.length - 1, offset: 0 };
}, []);
/**
* 应用格式到选中文本
*/
const applyFormat = useCallback((
type: TextFragmentType,
metadata?: any
) => {
const { start, end } = selectionRef.current;
if (start === end) {
// 没有选中文本,仅切换当前格式状态
setCurrentStyle(prev => ({
...prev,
[type === 'bold' ? 'fontWeight' : type === 'italic' ? 'fontStyle' : type]:
type === 'bold' ? (prev.fontWeight === 'bold' ? 'normal' : 'bold') :
type === 'italic' ? (prev.fontStyle === 'italic' ? 'normal' : 'italic') :
prev[type as keyof TextStyle],
}));
return;
}
setFragments(prev => {
const newFragments = [...prev];
const { fragmentIndex, offset } = getCursorPosition(newFragments, start);
// 处理跨片段的格式化
let currentPos = 0;
let selectionStart = start;
let selectionEnd = end;
const result: TextFragment[] = [];
for (let i = 0; i < newFragments.length; i++) {
const fragment = newFragments[i];
const fragStart = currentPos;
const fragEnd = currentPos + fragment.content.length;
if (fragEnd <= selectionStart || fragStart >= selectionEnd) {
// 不在选择范围内,保持原样
result.push(fragment);
} else {
// 在选择范围内,需要分割
const before = fragment.content.slice(
0,
Math.max(0, selectionStart - fragStart)
);
const middle = fragment.content.slice(
Math.max(0, selectionStart - fragStart),
Math.min(fragment.content.length, selectionEnd - fragStart)
);
const after = fragment.content.slice(
Math.min(fragment.content.length, selectionEnd - fragStart)
);
if (before) {
result.push({ ...fragment, content: before });
}
if (middle) {
result.push({
type,
content: middle,
styles: type === 'bold' ? { fontWeight: 'bold' as const } :
type === 'italic' ? { fontStyle: 'italic' as const } :
type === 'code' ? { fontFamily: 'monospace' } :
fragment.styles,
metadata,
});
}
if (after) {
result.push({ ...fragment, content: after });
}
}
currentPos = fragEnd;
}
// 合并相邻的相同类型片段
const merged = result.reduce((acc, frag) => {
const last = acc[acc.length - 1];
if (
last &&
last.type === frag.type &&
JSON.stringify(last.styles) === JSON.stringify(frag.styles) &&
JSON.stringify(last.metadata) === JSON.stringify(frag.metadata)
) {
last.content += frag.content;
} else {
acc.push({ ...frag });
}
return acc;
}, [] as TextFragment[]);
return merged;
});
setIsFormatting(true);
setTimeout(() => setIsFormatting(false), 100);
}, [getCursorPosition]);
/**
* 插入特殊内容
*/
const insertContent = useCallback((
type: 'mention' | 'hashtag' | 'emoji' | 'link',
content: string,
metadata?: any
) => {
const { start, end } = selectionRef.current;
setFragments(prev => {
const { fragmentIndex, offset } = getCursorPosition(prev, start);
const newFragments = [...prev];
const targetFragment = newFragments[fragmentIndex];
// 分割当前片段
const before = targetFragment.content.slice(0, offset);
const after = targetFragment.content.slice(offset);
// 创建新片段数组
const result: TextFragment[] = [];
if (before) {
result.push({ ...targetFragment, content: before });
}
result.push({
type,
content,
styles: type === 'link' ? { color: '#007AFF', textDecorationLine: 'underline' } :
type === 'mention' ? { color: '#007AFF', fontWeight: '600' } :
type === 'hashtag' ? { color: '#007AFF' } :
{},
metadata,
});
if (after) {
result.push({ ...targetFragment, content: after });
}
// 插入到原位置
newFragments.splice(fragmentIndex, 1, ...result);
return newFragments;
});
// 重新聚焦输入框
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}, [getCursorPosition]);
/**
* 处理文本变化
*/
const handleTextChange = useCallback((text: string) => {
// 简化处理:将整个文本作为一个文本片段
// 实际项目中需要更复杂的处理来保持格式
setFragments([{ type: 'text', content: text }]);
onTextChange?.([{ type: 'text', content: text }]);
}, [onTextChange]);
/**
* 处理光标位置变化
*/
const handleSelectionChange = useCallback((event: any) => {
selectionRef.current = event.nativeEvent.selection;
}, []);
/**
* 渲染格式化工具栏
*/
const renderToolbar = () => {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.toolbar}
contentContainerStyle={styles.toolbarContent}
>
<ToolbarButton
icon="B"
label="粗体"
onPress={() => applyFormat('bold')}
isActive={currentStyle.fontWeight === 'bold'}
/>
<ToolbarButton
icon="I"
label="斜体"
onPress={() => applyFormat('italic')}
isActive={currentStyle.fontStyle === 'italic'}
/>
<ToolbarButton
icon="#"
label="代码"
onPress={() => applyFormat('code')}
/>
{enableMention && (
<ToolbarButton
icon="@"
label="提及"
onPress={() => {
// 触发用户选择器
insertContent('mention', '@用户名', { userId: '123' });
}}
/>
)}
{enableHashtag && (
<ToolbarButton
icon="#"
label="话题"
onPress={() => {
insertContent('hashtag', '#话题');
}}
/>
)}
{enableEmoji && (
<ToolbarButton
icon="😊"
label="表情"
onPress={() => {
insertContent('emoji', '😊');
}}
/>
)}
<ToolbarButton
icon="🔗"
label="链接"
onPress={() => {
const url = 'https://example.com';
insertContent('link', '链接文本', { url });
}}
/>
</ScrollView>
);
};
/**
* 获取显示文本
*/
const displayText = useMemo(() => {
return fragments.map(f => f.content).join('');
}, [fragments]);
/**
* 渲染预览
*/
const renderPreview = () => {
return (
<View style={styles.previewContainer}>
<Text style={styles.previewLabel}>预览:</Text>
<View style={styles.previewContent}>
{fragments.map((fragment, index) => (
<Text
key={index}
style={fragment.styles}
onPress={() => {
if (fragment.type === 'link' && fragment.metadata?.url) {
// 处理链接点击
console.log('Open link:', fragment.metadata.url);
}
}}
>
{fragment.content}
</Text>
))}
</View>
</View>
);
};
return (
<KeyboardAvoidingView
style={styles.container}
keyboardVerticalOffset={Platform.OS === 'ios' ? 100 : 0}
behavior="padding"
>
<View style={[styles.editorContainer, { minHeight, maxHeight }]}>
<TextInput
ref={inputRef}
style={[styles.input, { height }]}
value={displayText}
onChangeText={handleTextChange}
onSelectionChange={handleSelectionChange}
placeholder={placeholder}
placeholderTextColor="#999"
multiline
textAlignVertical="top"
onContentSizeChange={(event) => {
const newHeight = Math.min(
Math.max(event.nativeEvent.contentSize.height, minHeight),
maxHeight
);
setHeight(newHeight);
}}
maxLength={maxLength}
/>
</View>
{renderToolbar()}
{renderPreview()}
<View style={styles.footer}>
<Text style={styles.charCount}>
{displayText.length} / {maxLength}
</Text>
<TouchableOpacity
style={styles.submitButton}
onPress={() => onSubmit?.(fragments)}
>
<Text style={styles.submitButtonText}>发送</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
};
/**
* 工具栏按钮组件
*/
interface ToolbarButtonProps {
icon: string;
label: string;
onPress: () => void;
isActive?: boolean;
}
const ToolbarButton: React.FC<ToolbarButtonProps> = ({
icon,
label,
onPress,
isActive = false,
}) => {
return (
<TouchableOpacity
style={[styles.toolbarButton, isActive && styles.toolbarButtonActive]}
onPress={onPress}
activeOpacity={0.6}
>
<Text style={[styles.toolbarButtonIcon, isActive && styles.toolbarButtonIconActive]}>
{icon}
</Text>
<Text style={styles.toolbarButtonLabel}>{label}</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
borderRadius: 12,
overflow: 'hidden',
},
editorContainer: {
paddingHorizontal: 16,
paddingTop: 12,
},
input: {
fontSize: 16,
lineHeight: 24,
color: '#333',
},
toolbar: {
borderTopWidth: 1,
borderTopColor: '#E5E5E5',
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
},
toolbarContent: {
paddingHorizontal: 12,
paddingVertical: 8,
gap: 8,
},
toolbarButton: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: '#F5F5F5',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
toolbarButtonActive: {
backgroundColor: '#007AFF',
},
toolbarButtonIcon: {
fontSize: 16,
fontWeight: 'bold',
color: '#666',
},
toolbarButtonIconActive: {
color: '#fff',
},
toolbarButtonLabel: {
fontSize: 12,
color: '#666',
},
previewContainer: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#E5E5E5',
backgroundColor: '#FAFAFA',
},
previewLabel: {
fontSize: 12,
color: '#999',
marginBottom: 8,
},
previewContent: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 2,
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: '#E5E5E5',
},
charCount: {
fontSize: 13,
color: '#999',
},
submitButton: {
backgroundColor: '#007AFF',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
submitButtonText: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
});
export default RichTextEditor;
3.2 @提及功能实现
typescript
/**
* @提及建议组件
* 根据输入显示用户建议列表
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
FlatList,
TextInput,
} from 'react-native';
interface User {
id: string;
username: string;
avatar?: string;
}
interface MentionSuggestionProps {
visible: boolean;
triggerText: string;
onSelect: (user: User) => void;
onClose: () => void;
users: User[];
position: { x: number; y: number };
}
export const MentionSuggestion: React.FC<MentionSuggestionProps> = ({
visible,
triggerText,
onSelect,
onClose,
users,
position,
}) => {
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
useEffect(() => {
if (triggerText) {
const filtered = users.filter(user =>
user.username.toLowerCase().includes(triggerText.toLowerCase())
);
setFilteredUsers(filtered);
} else {
setFilteredUsers(users.slice(0, 5)); // 显示前5个
}
}, [triggerText, users]);
const handleSelect = useCallback((user: User) => {
onSelect(user);
onClose();
}, [onSelect, onClose]);
if (!visible) return null;
return (
<View style={[styles.container, { top: position.y + 30, left: position.x }]}>
<FlatList
data={filteredUsers}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.userItem}
onPress={() => handleSelect(item)}
>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{item.username.charAt(0).toUpperCase()}
</Text>
</View>
<Text style={styles.username}>{item.username}</Text>
</TouchableOpacity>
)}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>未找到用户</Text>
</View>
}
style={styles.list}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
backgroundColor: '#fff',
borderRadius: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
maxWidth: 250,
maxHeight: 200,
zIndex: 1000,
},
list: {
paddingVertical: 4,
},
userItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
gap: 10,
},
avatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
color: '#fff',
fontSize: 14,
fontWeight: 'bold',
},
username: {
fontSize: 15,
color: '#333',
},
emptyContainer: {
padding: 16,
alignItems: 'center',
},
emptyText: {
fontSize: 14,
color: '#999',
},
});
3.3 话题标签检测器
typescript
/**
* 话题标签检测器和高亮器
*/
import React, { useMemo } from 'react';
import { View, Text, StyleSheet } from 'react-native';
interface HashtagHighlighterProps {
text: string;
onHashtagPress?: (hashtag: string) => void;
}
export const HashtagHighlighter: React.FC<HashtagHighlighterProps> = ({
text,
onHashtagPress,
}) => {
const parsedText = useMemo(() => {
// 正则匹配话题标签
const hashtagRegex = /#[\p{L}\p{N}_]+/gu;
const parts: Array<{ text: string; isHashtag: boolean; tag?: string }> = [];
let lastIndex = 0;
let match;
while ((match = hashtagRegex.exec(text)) !== null) {
// 添加匹配前的文本
if (match.index > lastIndex) {
parts.push({
text: text.slice(lastIndex, match.index),
isHashtag: false,
});
}
// 添加话题标签
parts.push({
text: match[0],
isHashtag: true,
tag: match[0].slice(1),
});
lastIndex = hashtagRegex.lastIndex;
}
// 添加剩余文本
if (lastIndex < text.length) {
parts.push({
text: text.slice(lastIndex),
isHashtag: false,
});
}
return parts;
}, [text]);
return (
<View style={highlightStyles.container}>
{parsedText.map((part, index) => (
<Text
key={index}
style={part.isHashtag ? highlightStyles.hashtag : highlightStyles.text}
onPress={() => part.isHashtag && onHashtagPress?.(part.tag!)}
>
{part.text}
</Text>
))}
</View>
);
};
const highlightStyles = StyleSheet.create({
container: {
flexDirection: 'row',
flexWrap: 'wrap',
},
text: {
color: '#333',
fontSize: 16,
},
hashtag: {
color: '#007AFF',
fontSize: 16,
fontWeight: '500',
},
});
3.4 使用示例
typescript
/**
* 富文本编辑器使用示例
*/
import React, { useState } from 'react';
import { View, StyleSheet, ScrollView, Alert } from 'react-native';
import { RichTextEditor, TextFragment } from './RichTextEditor';
import { MentionSuggestion } from './MentionSuggestion';
const RichTextEditorDemo: React.FC = () => {
const [fragments, setFragments] = useState<TextFragment[]>([]);
const [mentionVisible, setMentionVisible] = useState(false);
const [triggerText, setTriggerText] = useState('');
const [mentionPosition, setMentionPosition] = useState({ x: 0, y: 0 });
// 模拟用户数据
const mockUsers = [
{ id: '1', username: '张三' },
{ id: '2', username: '李四' },
{ id: '3', username: '王五' },
{ id: '4', username: '赵六' },
{ id: '5', username: '孙七' },
];
const handleTextChange = (newFragments: TextFragment[]) => {
setFragments(newFragments);
// 检测@符号,触发提及建议
const lastFragment = newFragments[newFragments.length - 1];
if (lastFragment && lastFragment.content.endsWith('@')) {
setMentionVisible(true);
setTriggerText('');
setMentionPosition({ x: 100, y: 200 });
}
};
const handleSubmit = (submittedFragments: TextFragment[]) => {
console.log('提交内容:', submittedFragments);
Alert.alert('提交成功', '内容已准备发送');
};
const handleUserSelect = (user: any) => {
console.log('选择用户:', user);
// 实际项目中需要将用户信息插入到文本中
};
return (
<View style={demoStyles.container}>
<ScrollView contentContainerStyle={demoStyles.content}>
<RichTextEditor
placeholder="分享你的想法..."
maxLength={2000}
minHeight={150}
maxHeight={300}
onTextChange={handleTextChange}
onSubmit={handleSubmit}
enableMention
enableHashtag
enableEmoji
/>
<MentionSuggestion
visible={mentionVisible}
triggerText={triggerText}
onSelect={handleUserSelect}
onClose={() => setMentionVisible(false)}
users={mockUsers}
position={mentionPosition}
/>
</ScrollView>
</View>
);
};
const demoStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
content: {
padding: 16,
},
});
export default RichTextEditorDemo;
四、HarmonyOS平台优化
4.1 键盘避让优化
typescript
/**
* HarmonyOS键盘避让处理
*/
import { Platform, Keyboard } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
export function useKeyboardAdjustment() {
useFocusEffect(
React.useCallback(() => {
const subscription = Keyboard.addListener('keyboardDidShow', (e) => {
if (Platform.OS === 'harmony') {
// HarmonyOS特定处理
console.log('Keyboard height:', e.endCoordinates.height);
}
});
return () => subscription.remove();
}, [])
);
}
4.2 性能优化策略
| 优化点 | 实现方式 | 效果 |
|---|---|---|
| 文本解析 | 使用useMemo缓存解析结果 | 减少重复计算 |
| 片段合并 | 合并相邻相同类型片段 | 减少渲染节点 |
| 虚拟化长列表 | 使用FlatList渲染用户建议 | 优化大量数据场景 |
| 防抖处理 | 输入变化防抖300ms | 减少不必要的渲染 |
五、总结
本文详细介绍了在HarmonyOS平台上实现富文本编辑器的完整方案,涵盖:
- 核心功能:文本格式化、特殊内容插入、光标管理
- 高级特性:@提及、话题标签、链接检测
- 平台适配:键盘避让、性能优化
- 工程实践:模块化设计、可复用组件
📕个人领域 :Linux/C++/java/AI
🚀 个人主页 :有点流鼻涕 · CSDN
💬 座右铭 : "向光而行,沐光而生。"
