【HarmonyOS】day44:RN_of_HarmonyOS实战项目_富文本编辑器

【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 `![image](${image.uri})`;
  }

  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 核心收获

  1. 掌握 HarmonyOS RN 富文本编辑器架构设计
  2. 实现完整的文本格式化功能
  3. 学会图片、表情等多媒体内容插入
  4. 理解撤销/重做历史栈管理原理

7.2 后续扩展方向

  • 🔗 集成 @提及 和 #话题标签 功能
  • 📄 支持 Markdown 语法实时预览
  • 🎨 添加更多字体、颜色选择
  • ☁️ 集成云同步功能
  • 🤖 添加 AI 智能写作辅助

7.3 资源链接


💡 提示 :本文代码基于 HarmonyOS NEXT + React Native 0.72.5 编写,富文本功能为简化实现,生产环境建议使用成熟的富文本库如 react-native-cn-quill 并进行鸿蒙适配。
📢 欢迎加入开源鸿蒙跨平台开发者社区,一起探索更多可能性!


本文完 | 2026年2月

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

相关推荐
松叶似针2 小时前
Flutter三方库适配OpenHarmony【doc_text】— Word 文档格式深度科普:从 OLE2 到 OOXML
flutter·harmonyos
木斯佳2 小时前
HarmonyOS实战(解决方案篇)—从实战案例了解应用并发设计
华为·harmonyos
空白诗2 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、粒子系统与流体模拟:动态粒子的视觉盛宴
flutter·harmonyos
空白诗2 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、混沌理论与奇异吸引子:从洛伦兹到音乐的动态艺术
flutter·harmonyos
lbb 小魔仙3 小时前
鸿蒙跨平台实战:React Native在OpenHarmony上的Font字体降级策略详解
react native·华为·harmonyos
hqk3 小时前
鸿蒙项目实战:手把手带你从零架构 WanAndroid 鸿蒙版
前端·架构·harmonyos
早點睡3904 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、粒子物理引力场:万有引力与排斥逻辑
flutter·华为·harmonyos
lbb 小魔仙4 小时前
鸿蒙跨平台实战:React Native在OpenHarmony上的Font字体加载管理详解
react native·华为·harmonyos
2601_949593654 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、极坐标对称投影:万花筒般的几何韵律
flutter·华为·harmonyos