【HarmonyOS】RN_of_HarmonyOS实战项目_富文本编辑器
技术栈 :HarmonyOS NEXT + React Native 0.72.5 + TypeScript
更新时间 :2026年2月
阅读时间:约25分钟
📝 前言
在移动应用开发中,富文本编辑器是内容创作类应用的核心组件。无论是社交动态发布、文章撰写、评论回复,还是笔记记录,都离不开富文本编辑功能。
在HarmonyOS平台上使用React Native(RNOH)实现富文本编辑器,需要解决文本格式化 、工具栏交互 、内容渲染 、跨平台适配等多个技术难点。本文将带你从零开始,打造一个生产级的富文本编辑器组件。
🎯 一、项目背景与需求分析
1.1 核心功能需求
| 功能模块 | 描述 | 优先级 |
|---|---|---|
| 基础格式化 | 粗体、斜体、下划线、删除线 | ⭐⭐⭐ |
| 文本样式 | 字体大小、颜色、对齐方式 | ⭐⭐⭐ |
| 列表功能 | 有序列表、无序列表 | ⭐⭐ |
| 多媒体插入 | 图片、表情、@提及 | ⭐⭐ |
| 内容导出 | HTML、Markdown格式导出 | ⭐ |
| 撤销重做 | 操作历史管理 | ⭐ |
1.2 技术架构
┌─────────────────────────────────────────────────────────────────┐
│ 富文本编辑器技术架构 │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 工具栏层 │ │ 编辑区域 │ │ 渲染层 │ │
│ │ Toolbar │ │ Editor │ │ Renderer │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 状态管理层 │ │
│ │ ContentState + SelectionState │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 鸿蒙适配层 │ │
│ │ TextInput + Custom Native Module │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
1.3 技术挑战
┌─────────────────────────────────────────────────────────┐
│ HarmonyOS RN 富文本编辑器技术挑战 │
├─────────────────────────────────────────────────────────┤
│ 1. TextInput组件不支持富文本格式 │
│ 2. 需要自定义Native Module实现格式控制 │
│ 3. 工具栏与编辑区域的同步状态管理 │
│ 4. 图片等多媒体内容的插入与渲染 │
│ 5. 撤销/重做操作的历史栈管理 │
│ 6. HarmonyOS键盘事件的特殊处理 │
└─────────────────────────────────────────────────────────┘
🛠️ 二、环境准备
2.1 开发环境要求
bash
# Node.js 版本
node -v # 建议 v18+
# React Native 版本
npx react-native -v # 0.72.5
# HarmonyOS SDK
hvigorw --version # API 20+
# RNOH 版本
npm list react-native-openharmony # 0.72.5
2.2 项目依赖安装
bash
# 初始化RNOH项目
npx react-native init RichTextEditor --template @react-native-openharmony/template
# 安装必要依赖
npm install @react-native-async-storage/async-storage
npm install react-native-image-picker
npm install @react-native-community/clipboard
# 鸿蒙特有依赖
npm install react-native-harmony-device-info
2.3 权限配置
在 module.json5 中添加必要权限:
json5
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.READ_MEDIA",
"reason": "$string:read_media_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.WRITE_MEDIA",
"reason": "$string:write_media_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
💻 三、核心组件实现
3.1 富文本内容状态管理
tsx
// types/RichTextTypes.ts
export interface RichTextState {
content: string;
html: string;
markdown: string;
selection: SelectionState;
formats: FormatState;
}
export interface SelectionState {
start: number;
end: number;
text: string;
}
export interface FormatState {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
fontSize: number;
color: string;
align: 'left' | 'center' | 'right' | 'justify';
listType: 'none' | 'ordered' | 'unordered';
}
export interface HistoryItem {
content: string;
selection: SelectionState;
timestamp: number;
}
export const DEFAULT_FORMAT_STATE: FormatState = {
bold: false,
italic: false,
underline: false,
strikethrough: false,
fontSize: 16,
color: '#000000',
align: 'left',
listType: 'none',
};
3.2 富文本编辑器核心Hook
tsx
// hooks/useRichTextEditor.ts
import { useState, useCallback, useMemo, useRef } from 'react';
import { RichTextState, FormatState, HistoryItem, SelectionState } from '../types/RichTextTypes';
const MAX_HISTORY = 50;
const HISTORY_DELAY = 500; // 毫秒
export const useRichTextEditor = (initialContent: string = '') => {
const [content, setContent] = useState<string>(initialContent);
const [formats, setFormats] = useState<FormatState>({
bold: false,
italic: false,
underline: false,
strikethrough: false,
fontSize: 16,
color: '#000000',
align: 'left',
listType: 'none',
});
const [selection, setSelection] = useState<SelectionState>({
start: 0,
end: 0,
text: '',
});
const historyRef = useRef<HistoryItem[]>([]);
const historyIndexRef = useRef<number>(-1);
const lastSaveTimeRef = useRef<number>(0);
const isUndoRedoRef = useRef<boolean>(false);
// 保存到历史记录
const saveToHistory = useCallback((newContent: string, newSelection: SelectionState) => {
const now = Date.now();
// 如果是撤销重做操作,不重复保存
if (isUndoRedoRef.current) {
isUndoRedoRef.current = false;
return;
}
// 防抖保存
if (now - lastSaveTimeRef.current < HISTORY_DELAY) {
return;
}
lastSaveTimeRef.current = now;
const historyItem: HistoryItem = {
content: newContent,
selection: newSelection,
timestamp: now,
};
// 如果当前有重做历史,清除
if (historyIndexRef.current < historyRef.current.length - 1) {
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
}
historyRef.current.push(historyItem);
// 限制历史记录数量
if (historyRef.current.length > MAX_HISTORY) {
historyRef.current.shift();
} else {
historyIndexRef.current++;
}
}, []);
// 更新内容
const updateContent = useCallback((newContent: string) => {
setContent(newContent);
saveToHistory(newContent, selection);
}, [selection, saveToHistory]);
// 更新选择范围
const updateSelection = useCallback((newSelection: SelectionState) => {
setSelection(newSelection);
}, []);
// 切换格式
const toggleFormat = useCallback((formatKey: keyof FormatState, value?: any) => {
setFormats(prev => {
const newFormats = { ...prev };
if (typeof value === 'boolean') {
newFormats[formatKey] = value;
} else if (formatKey === 'fontSize' || formatKey === 'color' || formatKey === 'align' || formatKey === 'listType') {
newFormats[formatKey] = value;
} else {
newFormats[formatKey] = !prev[formatKey];
}
return newFormats;
});
}, []);
// 应用格式到选中文字
const applyFormat = useCallback((text: string, format: FormatState): string => {
let formatted = text;
// 这里简化处理,实际需要更复杂的HTML/Markdown生成逻辑
if (format.bold) {
formatted = `**${formatted}**`;
}
if (format.italic) {
formatted = `*${formatted}*`;
}
if (format.underline) {
formatted = `<u>${formatted}</u>`;
}
if (format.strikethrough) {
formatted = `~~${formatted}~~`;
}
return formatted;
}, []);
// 撤销
const undo = useCallback((): boolean => {
if (historyIndexRef.current <= 0) {
return false;
}
isUndoRedoRef.current = true;
historyIndexRef.current--;
const prevItem = historyRef.current[historyIndexRef.current];
setContent(prevItem.content);
setSelection(prevItem.selection);
return true;
}, []);
// 重做
const redo = useCallback((): boolean => {
if (historyIndexRef.current >= historyRef.current.length - 1) {
return false;
}
isUndoRedoRef.current = true;
historyIndexRef.current++;
const nextItem = historyRef.current[historyIndexRef.current];
setContent(nextItem.content);
setSelection(nextItem.selection);
return true;
}, []);
// 导出HTML
const exportHTML = useMemo(() => {
// 简化实现,实际需要更完整的HTML生成
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/~~(.*?)~~/g, '<del>$1</del>')
.replace(/<u>(.*?)<\/u>/g, '<u>$1</u>')
.replace(/\n/g, '<br/>');
}, [content]);
// 导出Markdown
const exportMarkdown = useMemo(() => {
return content;
}, [content]);
// 清空内容
const clearContent = useCallback(() => {
setContent('');
setFormats({
bold: false,
italic: false,
underline: false,
strikethrough: false,
fontSize: 16,
color: '#000000',
align: 'left',
listType: 'none',
});
saveToHistory('', { start: 0, end: 0, text: '' });
}, [saveToHistory]);
return {
content,
formats,
selection,
updateContent,
updateSelection,
toggleFormat,
applyFormat,
undo,
redo,
exportHTML,
exportMarkdown,
clearContent,
canUndo: historyIndexRef.current > 0,
canRedo: historyIndexRef.current < historyRef.current.length - 1,
};
};
3.3 工具栏组件
tsx
// components/Toolbar.tsx
import React, { useCallback } from 'react';
import {
View,
TouchableOpacity,
Text,
StyleSheet,
ScrollView,
} from 'react-native';
import { FormatState } from '../types/RichTextTypes';
interface ToolbarProps {
formats: FormatState;
onToggleFormat: (key: keyof FormatState, value?: any) => void;
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
onInsertImage: () => void;
onInsertEmoji: () => void;
}
interface ToolbarButtonProps {
icon: string;
label: string;
isActive?: boolean;
onPress: () => void;
disabled?: boolean;
}
const ToolbarButton: React.FC<ToolbarButtonProps> = ({
icon,
label,
isActive = false,
onPress,
disabled = false,
}) => (
<TouchableOpacity
style={[styles.button, isActive && styles.buttonActive, disabled && styles.buttonDisabled]}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
<Text style={[styles.buttonIcon, isActive && styles.buttonIconActive]}>
{icon}
</Text>
<Text style={[styles.buttonLabel, isActive && styles.buttonLabelActive]}>
{label}
</Text>
</TouchableOpacity>
);
export const Toolbar: React.FC<ToolbarProps> = ({
formats,
onToggleFormat,
onUndo,
onRedo,
canUndo,
canRedo,
onInsertImage,
onInsertEmoji,
}) => {
const handleBold = useCallback(() => {
onToggleFormat('bold');
}, [onToggleFormat]);
const handleItalic = useCallback(() => {
onToggleFormat('italic');
}, [onToggleFormat]);
const handleUnderline = useCallback(() => {
onToggleFormat('underline');
}, [onToggleFormat]);
const handleStrikethrough = useCallback(() => {
onToggleFormat('strikethrough');
}, [onToggleFormat]);
const handleAlignLeft = useCallback(() => {
onToggleFormat('align', 'left');
}, [onToggleFormat]);
const handleAlignCenter = useCallback(() => {
onToggleFormat('align', 'center');
}, [onToggleFormat]);
const handleAlignRight = useCallback(() => {
onToggleFormat('align', 'right');
}, [onToggleFormat]);
const handleListOrdered = useCallback(() => {
onToggleFormat('listType', formats.listType === 'ordered' ? 'none' : 'ordered');
}, [onToggleFormat, formats.listType]);
const handleListUnordered = useCallback(() => {
onToggleFormat('listType', formats.listType === 'unordered' ? 'none' : 'unordered');
}, [onToggleFormat, formats.listType]);
return (
<View style={styles.container}>
{/* 第一行:基础格式 */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.scroll}>
<View style={styles.row}>
<ToolbarButton
icon="↩"
label="撤销"
onPress={onUndo}
disabled={!canUndo}
/>
<ToolbarButton
icon="↪"
label="重做"
onPress={onRedo}
disabled={!canRedo}
/>
<View style={styles.separator} />
<ToolbarButton
icon="B"
label="粗体"
isActive={formats.bold}
onPress={handleBold}
/>
<ToolbarButton
icon="I"
label="斜体"
isActive={formats.italic}
onPress={handleItalic}
/>
<ToolbarButton
icon="U"
label="下划线"
isActive={formats.underline}
onPress={handleUnderline}
/>
<ToolbarButton
icon="S"
label="删除线"
isActive={formats.strikethrough}
onPress={handleStrikethrough}
/>
<View style={styles.separator} />
<ToolbarButton
icon="⫷"
label="左对齐"
isActive={formats.align === 'left'}
onPress={handleAlignLeft}
/>
<ToolbarButton
icon="⫸"
label="居中"
isActive={formats.align === 'center'}
onPress={handleAlignCenter}
/>
<ToolbarButton
icon="⫹"
label="右对齐"
isActive={formats.align === 'right'}
onPress={handleAlignRight}
/>
<View style={styles.separator} />
<ToolbarButton
icon="1."
label="有序"
isActive={formats.listType === 'ordered'}
onPress={handleListOrdered}
/>
<ToolbarButton
icon="•"
label="无序"
isActive={formats.listType === 'unordered'}
onPress={handleListUnordered}
/>
<View style={styles.separator} />
<ToolbarButton
icon="🖼"
label="图片"
onPress={onInsertImage}
/>
<ToolbarButton
icon="😊"
label="表情"
onPress={onInsertEmoji}
/>
</View>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
scroll: {
maxHeight: 120,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 8,
},
button: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 6,
minWidth: 50,
},
buttonActive: {
backgroundColor: '#e3f2fd',
},
buttonDisabled: {
opacity: 0.4,
},
buttonIcon: {
fontSize: 18,
color: '#666',
fontWeight: '600',
},
buttonIconActive: {
color: '#1976d2',
},
buttonLabel: {
fontSize: 10,
color: '#999',
marginTop: 2,
},
buttonLabelActive: {
color: '#1976d2',
},
separator: {
width: 1,
height: 30,
backgroundColor: '#e0e0e0',
marginHorizontal: 8,
},
});
3.4 编辑区域组件
tsx
// components/Editor.tsx
import React, { useCallback, useRef } from 'react';
import {
View,
TextInput,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { FormatState, SelectionState } from '../types/RichTextTypes';
interface EditorProps {
content: string;
formats: FormatState;
placeholder?: string;
onContentChange: (content: string) => void;
onSelectionChange: (selection: SelectionState) => void;
}
export const Editor: React.FC<EditorProps> = ({
content,
formats,
placeholder = '开始编辑...',
onContentChange,
onSelectionChange,
}) => {
const inputRef = useRef<TextInput>(null);
// 处理文本变化
const handleChangeText = useCallback((text: string) => {
onContentChange(text);
}, [onContentChange]);
// 处理选择变化
const handleSelectionChange = useCallback((event: any) => {
const { selection } = event.nativeEvent;
const selectedText = content.substring(selection.start, selection.end);
onSelectionChange({
start: selection.start,
end: selection.end,
text: selectedText,
});
}, [content, onSelectionChange]);
// 聚焦编辑器
const focus = useCallback(() => {
inputRef.current?.focus();
}, []);
// 暴露focus方法
React.useImperativeHandle(ref, () => ({ focus }));
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'harmony' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'harmony' ? 0 : 64}
>
<View style={styles.editorContainer}>
<TextInput
ref={inputRef}
style={[
styles.input,
{
fontSize: formats.fontSize,
color: formats.color,
textAlign: formats.align,
},
]}
value={content}
onChangeText={handleChangeText}
onSelectionChange={handleSelectionChange}
placeholder={placeholder}
placeholderTextColor="#999"
multiline
textAlignVertical="top"
autoFocus
autoFocusOnMount
returnKeyType="default"
blurOnSubmit={false}
/>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
editorContainer: {
flex: 1,
backgroundColor: '#fff',
padding: 16,
},
input: {
flex: 1,
fontSize: 16,
lineHeight: 24,
minHeight: 300,
},
});
3.5 图片插入功能
tsx
// utils/ImagePicker.ts
import { launchImageLibrary, ImageLibraryOptions, Asset } from 'react-native-image-picker';
export interface InsertedImage {
uri: string;
width: number;
height: number;
type: string;
}
export class ImagePickerUtil {
static async pickImage(): Promise<InsertedImage | null> {
const options: ImageLibraryOptions = {
mediaType: 'photo',
maxWidth: 1920,
maxHeight: 1920,
quality: 0.8,
includeBase64: false,
};
try {
const result = await launchImageLibrary(options);
if (result.didCancel) {
return null;
}
if (result.errorCode) {
console.error('图片选择错误:', result.errorMessage);
return null;
}
const asset = result.assets?.[0];
if (!asset) {
return null;
}
return {
uri: asset.uri || '',
width: asset.width || 0,
height: asset.height || 0,
type: asset.type || 'image',
};
} catch (error) {
console.error('图片选择异常:', error);
return null;
}
}
static generateImageMarkdown(image: InsertedImage): string {
return ``;
}
static generateImageHTML(image: InsertedImage): string {
return `<img src="${image.uri}" width="${image.width}" height="${image.height}" />`;
}
}
3.6 表情选择器组件
tsx
// components/EmojiPicker.tsx
import React, { useState, useCallback } from 'react';
import {
View,
Modal,
TouchableOpacity,
Text,
StyleSheet,
FlatList,
} from 'react-native';
const EMOJI_CATEGORIES = [
{
name: '常用',
emojis: ['😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', '😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗'],
},
{
name: '表情',
emojis: ['🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮', '🤐', '😯', '😪', '😫', '😴', '😌', '😛', '😜', '😝', '🤤'],
},
{
name: '手势',
emojis: ['👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '👈', '👉', '👆', '👇', '☝️', '✋', '🤚', '🖐', '🖖'],
},
{
name: '爱心',
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️'],
},
{
name: '动物',
emojis: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔', '🐧', '🐦', '🐤', '🦆'],
},
{
name: '食物',
emojis: ['🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🍆', '🥑', '🥦'],
},
];
interface EmojiPickerProps {
visible: boolean;
onEmojiSelect: (emoji: string) => void;
onClose: () => void;
}
export const EmojiPicker: React.FC<EmojiPickerProps> = ({
visible,
onEmojiSelect,
onClose,
}) => {
const [selectedCategory, setSelectedCategory] = useState(0);
const handleEmojiPress = useCallback((emoji: string) => {
onEmojiSelect(emoji);
onClose();
}, [onEmojiSelect, onClose]);
const renderCategoryTab = useCallback((category: any, index: number) => (
<TouchableOpacity
key={index}
style={[
styles.categoryTab,
selectedCategory === index && styles.categoryTabActive,
]}
onPress={() => setSelectedCategory(index)}
>
<Text style={styles.categoryTabText}>{category.name}</Text>
</TouchableOpacity>
), [selectedCategory]);
const renderEmoji = useCallback(({ item }: { item: string }) => (
<TouchableOpacity
style={styles.emojiItem}
onPress={() => handleEmojiPress(item)}
activeOpacity={0.7}
>
<Text style={styles.emojiText}>{item}</Text>
</TouchableOpacity>
), [handleEmojiPress]);
return (
<Modal
visible={visible}
animationType="slide"
transparent
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
{/* 分类标签 */}
<View style={styles.categoryTabs}>
<FlatList
data={EMOJI_CATEGORIES}
horizontal
showsHorizontalScrollIndicator={false}
renderItem={({ item, index }) => renderCategoryTab(item, index)}
keyExtractor={(_, index) => index.toString()}
/>
</View>
{/* 表情列表 */}
<FlatList
data={EMOJI_CATEGORIES[selectedCategory].emojis}
numColumns={8}
showsVerticalScrollIndicator={false}
renderItem={renderEmoji}
keyExtractor={(item, index) => `${selectedCategory}-${index}`}
style={styles.emojiList}
/>
{/* 关闭按钮 */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeButtonText}>完成</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: '#fff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: 400,
},
categoryTabs: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
paddingVertical: 8,
},
categoryTab: {
paddingHorizontal: 16,
paddingVertical: 8,
},
categoryTabActive: {
borderBottomWidth: 2,
borderBottomColor: '#1976d2',
},
categoryTabText: {
fontSize: 14,
color: '#666',
},
emojiList: {
padding: 8,
},
emojiItem: {
width: '12.5%',
aspectRatio: 1,
alignItems: 'center',
justifyContent: 'center',
},
emojiText: {
fontSize: 28,
},
closeButton: {
alignItems: 'center',
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
closeButtonText: {
fontSize: 16,
color: '#1976d2',
fontWeight: '600',
},
});
🎨 四、完整页面示例
tsx
// pages/RichTextEditorPage.tsx
import React, { useState, useCallback, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
SafeAreaView,
StatusBar,
Alert,
Keyboard,
} from 'react-native';
import { Toolbar } from '../components/Toolbar';
import { Editor } from '../components/Editor';
import { EmojiPicker } from '../components/EmojiPicker';
import { useRichTextEditor } from '../hooks/useRichTextEditor';
import { ImagePickerUtil } from '../utils/ImagePicker';
export const RichTextEditorPage: React.FC = () => {
const editorRef = useRef<any>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const {
content,
formats,
updateContent,
updateSelection,
toggleFormat,
undo,
redo,
exportHTML,
exportMarkdown,
clearContent,
canUndo,
canRedo,
} = useRichTextEditor('');
// 插入图片
const handleInsertImage = useCallback(async () => {
Keyboard.dismiss();
const image = await ImagePickerUtil.pickImage();
if (image) {
const imageMarkdown = ImagePickerUtil.generateImageMarkdown(image);
updateContent(content + '\n' + imageMarkdown + '\n');
}
}, [content, updateContent]);
// 插入表情
const handleInsertEmoji = useCallback(() => {
setShowEmojiPicker(true);
}, []);
const handleEmojiSelect = useCallback((emoji: string) => {
updateContent(content + emoji);
}, [content, updateContent]);
// 保存内容
const handleSave = useCallback(() => {
if (content.trim().length === 0) {
Alert.alert('提示', '内容不能为空');
return;
}
Alert.alert(
'保存成功',
`内容长度:${content.length} 字符\nHTML长度:${exportHTML.length} 字符`,
[{ text: '确定' }]
);
}, [content, exportHTML]);
// 预览HTML
const handlePreview = useCallback(() => {
Alert.alert('HTML预览', exportHTML, [{ text: '确定' }]);
}, [exportHTML]);
// 清空内容
const handleClear = useCallback(() => {
Alert.alert(
'确认清空',
'确定要清空所有内容吗?此操作不可恢复。',
[
{ text: '取消', style: 'cancel' },
{ text: '确定', style: 'destructive', onPress: clearContent },
]
);
}, [clearContent]);
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" />
{/* 顶部导航栏 */}
<View style={styles.header}>
<Text style={styles.title}>富文本编辑器</Text>
<View style={styles.headerActions}>
<TouchableOpacity style={styles.headerButton} onPress={handlePreview}>
<Text style={styles.headerButtonText}>预览</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.headerButton, styles.saveButton]} onPress={handleSave}>
<Text style={[styles.headerButtonText, styles.saveButtonText]}>保存</Text>
</TouchableOpacity>
</View>
</View>
{/* 工具栏 */}
<Toolbar
formats={formats}
onToggleFormat={toggleFormat}
onUndo={undo}
onRedo={redo}
canUndo={canUndo}
canRedo={canRedo}
onInsertImage={handleInsertImage}
onInsertEmoji={handleInsertEmoji}
/>
{/* 编辑区域 */}
<Editor
ref={editorRef}
content={content}
formats={formats}
placeholder="开始编辑你的内容..."
onContentChange={updateContent}
onSelectionChange={updateSelection}
/>
{/* 底部操作栏 */}
<View style={styles.footer}>
<TouchableOpacity style={styles.footerButton} onPress={handleClear}>
<Text style={styles.footerButtonText}>🗑 清空</Text>
</TouchableOpacity>
<Text style={styles.charCount}>
{content.length} 字符 | {content.split(/\s+/).filter(Boolean).length} 词
</Text>
</View>
{/* 表情选择器 */}
<EmojiPicker
visible={showEmojiPicker}
onEmojiSelect={handleEmojiSelect}
onClose={() => setShowEmojiPicker(false)}
/>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#333',
},
headerActions: {
flexDirection: 'row',
gap: 12,
},
headerButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#f0f0f0',
},
saveButton: {
backgroundColor: '#1976d2',
},
headerButtonText: {
fontSize: 14,
color: '#333',
fontWeight: '500',
},
saveButtonText: {
color: '#fff',
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
footerButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: '#ffebee',
},
footerButtonText: {
fontSize: 13,
color: '#d32f2f',
},
charCount: {
fontSize: 13,
color: '#999',
},
});
⚠️ 五、常见问题与解决方案
5.1 TextInput多行输入高度问题
tsx
// 问题:HarmonyOS上TextInput多行输入高度不自动增长
// 解决方案:使用onContentSizeChange动态调整高度
const [inputHeight, setInputHeight] = useState(300);
<TextInput
style={[styles.input, { height: Math.max(300, inputHeight) }]}
onContentSizeChange={(event) => {
setInputHeight(event.nativeEvent.contentSize.height);
}}
multiline
/>
5.2 键盘遮挡输入框
tsx
// 使用KeyboardAvoidingView适配
import { KeyboardAvoidingView, Platform } from 'react-native';
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'harmony' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'harmony' ? 0 : 64}
>
{/* 编辑器内容 */}
</KeyboardAvoidingView>
5.3 格式状态同步问题
tsx
// 问题:工具栏状态与编辑内容不同步
// 解决方案:监听selectionChange事件,分析选中内容的格式
const analyzeSelectionFormat = (text: string): FormatState => {
return {
bold: /\*\*(.*?)\*\*/.test(text),
italic: /\*(.*?)\*/.test(text),
underline: /<u>(.*?)<\/u>/.test(text),
strikethrough: /~~(.*?)~~/.test(text),
// ... 其他格式
};
};
5.4 图片插入后光标位置
tsx
// 记录插入前的光标位置,插入后恢复
const insertAtCursor = (text: string, insertText: string): { newText: string, newCursor: number } => {
const before = text.substring(0, selection.start);
const after = text.substring(selection.end);
const newText = before + insertText + after;
const newCursor = selection.start + insertText.length;
return { newText, newCursor };
};
📊 六、性能优化建议
| 优化点 | 方案 | 效果 |
|---|---|---|
| 防抖保存 | 使用 debounce 延迟历史记录保存 |
减少内存占用 |
| 虚拟列表 | 表情选择器使用 FlatList |
提升渲染性能 |
| 状态拆分 | 分离格式状态与内容状态 | 减少不必要的重渲染 |
| 图片压缩 | 插入前压缩图片 | 减少内存占用 |
| 懒加载 | 工具栏按需渲染 | 提升首屏速度 |
tsx
// 防抖保存示例
import { useEffect, useRef } from 'react';
const useDebounce = (value: string, delay: number) => {
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
timeoutRef.current = setTimeout(() => {
// 执行保存操作
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [value, delay]);
};
🎯 七、总结与展望
7.1 核心收获
- 掌握 HarmonyOS RN 富文本编辑器架构设计
- 实现完整的文本格式化功能
- 学会图片、表情等多媒体内容插入
- 理解撤销/重做历史栈管理原理
7.2 后续扩展方向
- 🔗 集成 @提及 和 #话题标签 功能
- 📄 支持 Markdown 语法实时预览
- 🎨 添加更多字体、颜色选择
- ☁️ 集成云同步功能
- 🤖 添加 AI 智能写作辅助
7.3 资源链接
💡 提示 :本文代码基于 HarmonyOS NEXT + React Native 0.72.5 编写,富文本功能为简化实现,生产环境建议使用成熟的富文本库如
react-native-cn-quill并进行鸿蒙适配。
📢 欢迎加入开源鸿蒙跨平台开发者社区,一起探索更多可能性!
本文完 | 2026年2月
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net