熟悉我们HarmonyOS开发的老朋友一定知道,RichEditor组件是构建富文本编辑器的利器,支持图文混排、文本交互式编辑。但实际开发中,我们经常需要限制用户输入和粘贴的字符数,比如评论框限制500字、商品简介限制200字等。

问题来了:设置了最大字符数限制后,用户从其他地方复制一段超长文本粘贴到RichEditor,结果限制失效了,超长内容照样进去了。emmm,这用户体验就不太友好了。

有朋友会问,不对啊,我们不是有maxLength属性吗?遗憾的是,RichEditor目前没有内置的maxLength属性,需要我们自己实现完整的输入和粘贴控制逻辑。这篇文章就完整记录一下实现过程。

一、问题场景与核心思路

1.1 实际开发中的痛点

假设我们要开发一个社交应用的评论框:

  • 用户可以在RichEditor中输入文字、插入图片、添加表情

  • 需要限制总字符数不超过500(图片和表情也算字符)

  • 用户从其他应用复制内容粘贴时,不能超过限制

  • 粘贴内容超过限制时,需要自动裁剪并给用户提示

1.2 核心实现原理

// 核心思路拆解:
1. 实时统计:在onSelectionChange回调中实时计算当前内容长度
2. 输入拦截:在aboutToIMEInput回调中拦截输入法输入,超过限制就阻止
3. 粘贴处理:在onPaste回调中获取粘贴板内容,计算长度并裁剪
4. 智能计数:正确处理文本、图片、表情的字符计数

二、完整实现方案

2.1 项目结构与依赖

首先,我们需要创建一个完整的HarmonyOS项目结构:

MyRichEditorApp/
├── entry/
│   └── src/
│       └── main/
│           ├── ets/
│           │   └── RichEditorDemo.ets
│           ├── resources/
│           │   └── base/
│           │       └── media/
│           │           └── startIcon.png
│           └── module.json5
└── oh-package.json5

oh-package.json5中添加依赖:

{
  "license": "ISC",
  "devDependencies": {
    "@ohos/hypium": "1.0.0"
  },
  "name": "richeditordemo",
  "description": "RichEditor输入限制示例",
  "repository": {},
  "dependencies": {
    "@kit.BasicServicesKit": "1.0.0"
  }
}

2.2 核心实现代码

下面是完整的RichEditor输入限制实现:

import pasteboard from '@ohos.pasteboard';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct RichEditorLimitedLengthDemo {
  // RichEditor控制器
  controller: RichEditorController = new RichEditorController();
  options: RichEditorOptions = { controller: this.controller };
  
  // 状态变量
  @State maxContentLength: number = 100;  // 最大字符限制
  @State currentTextLength: number = 0;   // 当前字符数
  @State remainingChars: number = 100;     // 剩余字符数
  @State isExceeded: boolean = false;      // 是否超限
  
  // 图片占位符标识
  private readonly IMAGE_PLACEHOLDER = '&&img&&';
  private readonly AT_PLACEHOLDER = '&&at&&';
  private readonly TOPIC_PLACEHOLDER = '&&topic&&';

  /**
   * 统计当前RichEditor内容长度
   * 支持:文本、图片、表情
   */
  calculateCurrentTextLength(): number {
    const spans = this.controller.getSpans();
    let totalLength = 0;
    
    if (!spans || spans.length === 0) {
      return 0;
    }
    
    spans.forEach((span: RichEditorSpanResult) => {
      const textSpan = span as RichEditorTextSpanResult;
      
      if (textSpan.value) {
        // 处理文本内容
        const cleanText = textSpan.value.replace(
          new RegExp(`${this.IMAGE_PLACEHOLDER}|${this.AT_PLACEHOLDER}|${this.TOPIC_PLACEHOLDER}`, 'g'),
          ''
        );
        totalLength += this.countEffectiveLength(cleanText);
      } else {
        // 处理图片:每张图片算1个字符
        totalLength += 1;
      }
    });
    
    return totalLength;
  }

  /**
   * 计算有效文本长度
   * 考虑因素:Emoji表情占2个字符,需要特殊处理
   */
  countEffectiveLength(str: string): number {
    if (!str) return 0;
    
    const arr = Array.from(str);
    let emojiCount = 0;
    
    // 统计Emoji表情数量
    for (const char of arr) {
      // 判断是否为Emoji范围
      const code = char.codePointAt(0);
      if (code && code >= 0x1F600 && code <= 0x1F64F) {  // Emoji范围
        emojiCount++;
      } else if (code && code >= 0x1F300 && code <= 0x1F5FF) {  // 其他符号
        emojiCount++;
      } else if (code && code >= 0x1F680 && code <= 0x1F6FF) {  // 交通和地图符号
        emojiCount++;
      } else if (code && code >= 0x2600 && code <= 0x26FF) {  // 杂项符号
        emojiCount++;
      } else if (code && code >= 0x2700 && code <= 0x27BF) {  // 装饰符号
        emojiCount++;
      }
    }
    
    // 总长度减去Emoji个数(因为Emoji占2个字符,但只应算1个)
    return str.length - emojiCount;
  }

  /**
   * 输入法输入前的拦截处理
   */
  handleBeforeInput(value: RichEditorInsertValue): boolean {
    const currentLength = this.calculateCurrentTextLength();
    
    if (currentLength >= this.maxContentLength) {
      this.showToast(`最多只能输入${this.maxContentLength}个字`);
      return false;
    }
    
    const cleanInsertValue = value.insertValue.replace(
      new RegExp(`${this.IMAGE_PLACEHOLDER}|${this.AT_PLACEHOLDER}|${this.TOPIC_PLACEHOLDER}`, 'g'),
      ''
    );
    
    const availableLength = this.maxContentLength - currentLength;
    const insertLength = this.countEffectiveLength(cleanInsertValue);
    
    if (insertLength <= availableLength) {
      return true;  // 允许输入
    }
    
    // 需要裁剪
    const trimmedText = this.trimTextToLength(cleanInsertValue, availableLength);
    this.controller.addTextSpan(trimmedText, { 
      offset: this.controller.getCaretOffset(),
      style: { fontSize: 16, fontColor: Color.Black }
    });
    
    this.showToast(`内容已截取至${this.maxContentLength}个字`);
    return false;  // 阻止默认输入
  }

  /**
   * 粘贴处理
   */
  handlePaste(event?: PasteEvent) {
    if (!event || !event.preventDefault) {
      return;
    }
    
    // 阻止默认粘贴行为
    event.preventDefault();
    
    // 获取粘贴板内容
    pasteboard.getSystemPasteboard().getData((err: BusinessError, pasteData: pasteboard.PasteData) => {
      if (err) {
        console.error('获取粘贴板数据失败: ', err.message);
        this.showToast('粘贴失败,请重试');
        return;
      }
      
      this.processPasteData(pasteData);
    });
  }

  /**
   * 处理粘贴板数据
   */
  processPasteData(pasteData: pasteboard.PasteData) {
    const currentLength = this.calculateCurrentTextLength();
    
    for (let i = 0; i < pasteData.getRecordCount(); i++) {
      if (currentLength >= this.maxContentLength) {
        this.showToast(`最多只能输入${this.maxContentLength}个字`);
        return;
      }
      
      const record = pasteData.getRecord(i);
      
      if (record.plainText) {
        // 处理纯文本
        this.handleTextPaste(record.plainText, currentLength);
      } else if (record.uri) {
        // 处理图片URI
        this.handleImagePaste(record.uri, currentLength);
      } else if (record.htmlText) {
        // 处理HTML文本(可选,这里简化处理为纯文本)
        this.handleHtmlPaste(record.htmlText, currentLength);
      }
    }
  }

  /**
   * 处理文本粘贴
   */
  handleTextPaste(text: string, currentLength: number) {
    const textLength = this.countEffectiveLength(text);
    const availableLength = this.maxContentLength - currentLength;
    
    if (textLength <= availableLength) {
      // 可以完整粘贴
      this.controller.addTextSpan(text, { 
        style: { fontSize: 16, fontColor: Color.Black }
      });
      this.currentTextLength = currentLength + textLength;
    } else {
      // 需要裁剪
      this.currentTextLength = this.maxContentLength;
      const trimmedText = this.trimTextToLength(text, availableLength);
      this.controller.addTextSpan(trimmedText, { 
        style: { fontSize: 16, fontColor: Color.Black }
      });
      this.showToast(`粘贴内容已截取,最多${this.maxContentLength}个字`);
    }
    
    this.updateRemainingChars();
  }

  /**
   * 处理图片粘贴
   */
  handleImagePaste(uri: string, currentLength: number) {
    if (currentLength >= this.maxContentLength) {
      this.showToast(`最多只能输入${this.maxContentLength}个字`);
      return;
    }
    
    this.controller.addImageSpan(uri, {
      imageStyle: { size: ["57px", "57px"] }
    });
    
    this.currentTextLength = currentLength + 1;  // 图片算1个字符
    this.updateRemainingChars();
  }

  /**
   * 处理HTML粘贴(简化版)
   */
  handleHtmlPaste(html: string, currentLength: number) {
    // 提取纯文本(去除HTML标签)
    const plainText = html.replace(/<[^>]*>/g, '');
    this.handleTextPaste(plainText, currentLength);
  }

  /**
   * 文本裁剪到指定长度
   */
  trimTextToLength(text: string, maxLength: number): string {
    if (maxLength <= 0) return '';
    
    let result = '';
    let currentLength = 0;
    
    for (const char of text) {
      const charLength = this.countEffectiveLength(char);
      
      if (currentLength + charLength <= maxLength) {
        result += char;
        currentLength += charLength;
      } else {
        break;
      }
    }
    
    return result;
  }

  /**
   * 更新剩余字符数
   */
  updateRemainingChars() {
    this.currentTextLength = this.calculateCurrentTextLength();
    this.remainingChars = this.maxContentLength - this.currentTextLength;
    this.isExceeded = this.currentTextLength > this.maxContentLength;
  }

  /**
   * 显示Toast提示
   */
  showToast(message: string) {
    this.getUIContext().getPromptAction().showToast({ 
      message,
      duration: 2000 
    });
  }

  /**
   * 清除所有内容
   */
  clearAllContent() {
    this.controller.deleteByRange({ 
      start: 0, 
      end: this.controller.getContentsLength() 
    });
    this.updateRemainingChars();
  }

  /**
   * 插入示例内容(用于测试)
   */
  insertSampleContent() {
    const sampleText = "这是一个示例文本,包含Emoji😊和图片。";
    this.controller.addTextSpan(sampleText, { 
      style: { fontSize: 16, fontColor: Color.Blue }
    });
    
    // 插入图片
    this.controller.addImageSpan($r("app.media.startIcon"), {
      imageStyle: { size: ["57px", "57px"] }
    });
    
    this.updateRemainingChars();
  }

  build() {
    Column({ space: 20 }) {
      // 标题区域
      Text('富文本编辑器 - 输入限制演示')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1890ff')
        .width('100%')
        .textAlign(TextAlign.Center)
        .margin({ top: 20, bottom: 10 })
      
      // 字符统计显示
      Column({ space: 8 }) {
        Row({ space: 20 }) {
          Column({ space: 4 }) {
            Text('已输入')
              .fontSize(12)
              .fontColor('#666666')
            Text(`${this.currentTextLength}`)
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.isExceeded ? Color.Red : '#333333')
          }
          
          Column({ space: 4 }) {
            Text('剩余')
              .fontSize(12)
              .fontColor('#666666')
            Text(`${this.remainingChars}`)
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.remainingChars < 20 ? Color.Orange : '#52c41a')
          }
          
          Column({ space: 4 }) {
            Text('限制')
              .fontSize(12)
              .fontColor('#666666')
            Text(`${this.maxContentLength}`)
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1890ff')
          }
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceAround)
        
        // 进度条
        Progress({
          value: this.currentTextLength,
          total: this.maxContentLength
        })
        .width('90%')
        .height(6)
        .color({
          value: this.isExceeded ? Color.Red : 
                 this.remainingChars < 20 ? Color.Orange : '#52c41a'
        })
        .backgroundColor('#f0f0f0')
      }
      .width('100%')
      .padding(20)
      .backgroundColor(Color.White)
      .borderRadius(12)
      .shadow({ radius: 4, color: '#00000010' })
      
      // RichEditor编辑器
      Column({ space: 10 }) {
        Text('编辑区域')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .width('100%')
          .textAlign(TextAlign.Start)
        
        RichEditor({ controller: this.controller })
          .width('100%')
          .height(200)
          .backgroundColor(Color.White)
          .placeholder('请输入内容(支持文字、图片、表情)')
          .placeholderColor('#bfbfbf')
          .border({
            width: 1,
            color: this.isExceeded ? Color.Red : '#d9d9d9',
            radius: 8
          })
          .padding(12)
          .defaultFocus(false)
          .constraintSize({ maxHeight: 300 })
          .onPaste((event?: PasteEvent) => {
            this.handlePaste(event);
          })
          .aboutToIMEInput((value: RichEditorInsertValue) => {
            return this.handleBeforeInput(value);
          })
          .onSelectionChange(() => {
            this.updateRemainingChars();
          })
          .onReady(() => {
            console.log('RichEditor准备就绪');
          })
      }
      .width('100%')
      
      // 操作按钮区域
      Row({ space: 15 }) {
        Button('插入示例')
          .width(100)
          .height(40)
          .backgroundColor('#1890ff')
          .fontColor(Color.White)
          .onClick(() => this.insertSampleContent())
        
        Button('清空内容')
          .width(100)
          .height(40)
          .backgroundColor('#f0f0f0')
          .fontColor('#666666')
          .onClick(() => this.clearAllContent())
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 10 })
      
      // 功能说明区域
      Column({ space: 8 }) {
        Text('📋 功能说明')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
          .width('100%')
          .textAlign(TextAlign.Start)
        
        Column({ space: 6 }) {
          Row({ space: 8 }) {
            Text('•')
              .fontColor('#1890ff')
            Text('支持文字、图片、Emoji混合输入计数')
              .fontSize(13)
              .fontColor('#666666')
          }
          
          Row({ space: 8 }) {
            Text('•')
              .fontColor('#1890ff')
            Text('Emoji表情按1个字符计算(实际占2个字符)')
              .fontSize(13)
              .fontColor('#666666')
          }
          
          Row({ space: 8 }) {
            Text('•')
              .fontColor('#1890ff')
            Text('图片按1个字符计算')
              .fontSize(13)
              .fontColor('#666666')
          }
          
          Row({ space: 8 }) {
            Text('•')
              .fontColor('#1890ff')
            Text('粘贴超长内容自动裁剪')
              .fontSize(13)
              .fontColor('#666666')
          }
          
          Row({ space: 8 }) {
            Text('•')
              .fontColor('#1890ff')
            Text('实时显示剩余字数')
              .fontSize(13)
              .fontColor('#666666')
          }
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#f8f9fa')
      .borderRadius(8)
      .margin({ top: 10 })
      
      // 字符限制设置
      Column({ space: 10 }) {
        Text('⚙️ 设置字符限制')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
          .width('100%')
          .textAlign(TextAlign.Start)
        
        Row({ space: 10 }) {
          Slider({
            value: this.maxContentLength,
            min: 10,
            max: 500,
            step: 10,
            style: SliderStyle.OutSet
          })
          .width('70%')
          .showSteps(true)
          .trackThickness(6)
          .onChange((value: number) => {
            this.maxContentLength = value;
            this.updateRemainingChars();
          })
          
          Text(`${this.maxContentLength}`)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1890ff')
            .width(50)
            .textAlign(TextAlign.Center)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(Color.White)
      .border({ width: 1, color: '#f0f0f0', radius: 8 })
      .margin({ top: 10 })
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .backgroundColor('#f8f9fa')
  }
}

三、核心实现原理详解

3.1 字符统计的复杂性

字符统计看起来简单,实际要考虑多种情况:

/**
 * 字符统计需要考虑的复杂情况:
 * 1. 普通文本:每个字符算1个长度
 * 2. Emoji表情:存储占2个字符,但用户感知是1个
 * 3. 图片:每张图片算1个字符
 * 4. 特殊占位符:@用户、#话题# 等特殊标记
 * 5. 混合内容:文本+图片+Emoji混合
 * 
 * 核心挑战:如何准确识别和计数
 */

3.2 输入拦截的时机控制

RichEditor提供了几个关键的生命周期回调:

// 1. aboutToIMEInput:输入法输入前触发
//    最佳拦截时机,可以阻止输入
aboutToIMEInput((value: RichEditorInsertValue) => {
  // 返回false阻止输入,返回true允许输入
})

// 2. onPaste:粘贴时触发
//    可以获取粘贴板内容,覆盖默认粘贴行为
onPaste((event?: PasteEvent) => {
  event.preventDefault(); // 阻止默认粘贴
  // 自定义粘贴逻辑
})

// 3. onSelectionChange:选区变化时触发
//    用于实时统计字符数
onSelectionChange(() => {
  this.updateCharacterCount();
})

3.3 粘贴板数据处理

粘贴板可能包含多种类型的数据:

pasteboard.getSystemPasteboard().getData((err, pasteData) => {
  // 获取记录数量
  const recordCount = pasteData.getRecordCount();
  
  for (let i = 0; i < recordCount; i++) {
    const record = pasteData.getRecord(i);
    
    if (record.plainText) {
      // 纯文本
      console.log('文本内容:', record.plainText);
    } else if (record.uri) {
      // URI(通常是图片)
      console.log('图片URI:', record.uri);
    } else if (record.htmlText) {
      // HTML文本
      console.log('HTML内容:', record.htmlText);
    } else if (record.pixelMap) {
      // 像素图
      console.log('像素图数据');
    }
  }
});

四、高级功能扩展

4.1 支持@用户和#话题#标记

在实际社交应用中,我们经常需要支持特殊标记:

/**
 * 扩展:支持特殊标记
 */
class RichEditorWithMentions extends RichEditorLimitedLengthDemo {
  // 插入@用户
  insertMention(userName: string, userId: string) {
    const mentionText = `@${userName}`;
    const mentionData = {
      type: 'mention',
      userId: userId,
      text: mentionText
    };
    
    // 插入带有数据的特殊文本
    this.controller.addTextSpan(mentionText, {
      style: { 
        fontSize: 16, 
        fontColor: Color.Blue,
        backgroundColor: '#e6f7ff'
      },
      data: JSON.stringify(mentionData)
    });
  }
  
  // 插入#话题#
  insertTopic(topicName: string, topicId: string) {
    const topicText = `#${topicName}#`;
    const topicData = {
      type: 'topic',
      topicId: topicId,
      text: topicText
    };
    
    this.controller.addTextSpan(topicText, {
      style: { 
        fontSize: 16, 
        fontColor: Color.Green,
        backgroundColor: '#f6ffed'
      },
      data: JSON.stringify(topicData)
    });
  }
  
  // 重写字符统计,特殊标记算1个字符
  override calculateCurrentTextLength(): number {
    const spans = this.controller.getSpans();
    let totalLength = 0;
    
    spans?.forEach(span => {
      const textSpan = span as RichEditorTextSpanResult;
      
      if (textSpan.value) {
        if (textSpan.data) {
          // 特殊标记(@用户、#话题#)算1个字符
          try {
            const data = JSON.parse(textSpan.data);
            if (data.type === 'mention' || data.type === 'topic') {
              totalLength += 1;
              return;
            }
          } catch (e) {
            // 解析失败,按普通文本处理
          }
        }
        
        // 普通文本
        const cleanText = textSpan.value.replace(
          new RegExp(`${this.IMAGE_PLACEHOLDER}|${this.AT_PLACEHOLDER}|${this.TOPIC_PLACEHOLDER}`, 'g'),
          ''
        );
        totalLength += this.countEffectiveLength(cleanText);
      } else {
        // 图片
        totalLength += 1;
      }
    });
    
    return totalLength;
  }
}

4.2 支持撤回/重做功能

/**
 * 扩展:添加撤回/重做功能
 */
class RichEditorWithUndoRedo extends RichEditorLimitedLengthDemo {
  private history: string[] = [];  // 历史记录
  private historyIndex: number = -1;  // 当前历史位置
  
  // 保存状态到历史
  saveState() {
    const content = this.controller.getContents();
    this.history = this.history.slice(0, this.historyIndex + 1);
    this.history.push(content);
    this.historyIndex++;
    
    // 限制历史记录数量
    if (this.history.length > 50) {
      this.history.shift();
      this.historyIndex--;
    }
  }
  
  // 撤回
  undo() {
    if (this.historyIndex > 0) {
      this.historyIndex--;
      this.controller.setContents(this.history[this.historyIndex]);
      this.updateRemainingChars();
    }
  }
  
  // 重做
  redo() {
    if (this.historyIndex < this.history.length - 1) {
      this.historyIndex++;
      this.controller.setContents(this.history[this.historyIndex]);
      this.updateRemainingChars();
    }
  }
  
  // 在输入和粘贴时自动保存状态
  override handleBeforeInput(value: RichEditorInsertValue): boolean {
    this.saveState();
    return super.handleBeforeInput(value);
  }
  
  override handlePaste(event?: PasteEvent) {
    this.saveState();
    super.handlePaste(event);
  }
}

4.3 支持多语言和表情优化

/**
 * 扩展:更好的多语言和表情支持
 */
class RichEditorWithI18n extends RichEditorLimitedLengthDemo {
  // 更准确的Emoji检测
  private emojiRegex = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu;
  
  // 改进的字符计数
  override countEffectiveLength(str: string): number {
    if (!str) return 0;
    
    // 移除所有Emoji
    const textWithoutEmoji = str.replace(this.emojiRegex, '');
    
    // 统计Emoji数量
    const emojiMatches = str.match(this.emojiRegex);
    const emojiCount = emojiMatches ? emojiMatches.length : 0;
    
    // 处理组合字符(如带声调的字母)
    const normalized = str.normalize('NFC');
    let length = 0;
    
    for (let i = 0; i < normalized.length; i++) {
      const code = normalized.charCodeAt(i);
      
      // 跳过代理对的高代理部分
      if (code >= 0xD800 && code <= 0xDBFF) {
        i++; // 跳过低代理
      }
      
      length++;
    }
    
    // 总长度减去Emoji个数
    return length - emojiCount;
  }
  
  // 支持右到左语言(如阿拉伯语)
  setupRTLSupport() {
    // 设置文本方向
    this.controller.setTextDirection(TextDirection.Rtl);
    
    // 或者根据内容自动检测
    this.controller.setAutoTextDirection(true);
  }
}

五、性能优化建议

5.1 避免频繁计算

/**
 * 优化:防抖处理字符统计
 */
class OptimizedRichEditor extends RichEditorLimitedLengthDemo {
  private updateTimer: number | null = null;
  
  // 防抖更新
  debouncedUpdate() {
    if (this.updateTimer) {
      clearTimeout(this.updateTimer);
    }
    
    this.updateTimer = setTimeout(() => {
      this.updateRemainingChars();
      this.updateTimer = null;
    }, 300) as unknown as number; // 300ms防抖
  }
  
  // 重写监听
  override onSelectionChange() {
    this.debouncedUpdate();
  }
}

5.2 内存优化

/**
 * 优化:大文本处理
 */
class MemoryOptimizedRichEditor extends RichEditorLimitedLengthDemo {
  // 限制最大行数
  private maxLines: number = 1000;
  
  // 检查并限制行数
  checkAndLimitLines() {
    const text = this.controller.getTextContent();
    const lines = text.split('\n');
    
    if (lines.length > this.maxLines) {
      // 保留最近的行
      const recentLines = lines.slice(-this.maxLines);
      this.controller.setTextContent(recentLines.join('\n'));
      this.showToast(`已限制为${this.maxLines}行`);
    }
  }
  
  // 定期清理
  scheduleCleanup() {
    setInterval(() => {
      this.checkAndLimitLines();
    }, 60000); // 每分钟检查一次
  }
}

六、测试用例

6.1 单元测试示例

/**
 * 字符计数测试
 */
describe('RichEditor字符计数测试', () => {
  it('应该正确计算纯文本长度', () => {
    const editor = new RichEditorLimitedLengthDemo();
    const text = "Hello World";
    expect(editor.countEffectiveLength(text)).toBe(11);
  });
  
  it('应该正确处理Emoji', () => {
    const editor = new RichEditorLimitedLengthDemo();
    const text = "Hello 😊 World";
    expect(editor.countEffectiveLength(text)).toBe(12); // 15-1=14? 需要实际测试
  });
  
  it('应该正确处理混合内容', () => {
    const editor = new RichEditorLimitedLengthDemo();
    editor.controller.addTextSpan("文本", {});
    editor.controller.addImageSpan("image_uri", {});
    editor.controller.addTextSpan("😊", {});
    
    expect(editor.calculateCurrentTextLength()).toBe(4); // 2+1+1=4
  });
});

/**
 * 粘贴功能测试
 */
describe('RichEditor粘贴测试', () => {
  it('应该正确处理短文本粘贴', () => {
    const editor = new RichEditorLimitedLengthDemo();
    editor.maxContentLength = 10;
    editor.handleTextPaste("Hello", 0);
    expect(editor.currentTextLength).toBe(5);
  });
  
  it('应该裁剪超长文本粘贴', () => {
    const editor = new RichEditorLimitedLengthDemo();
    editor.maxContentLength = 5;
    editor.handleTextPaste("Hello World", 0);
    expect(editor.currentTextLength).toBe(5);
  });
});

七、常见问题与解决方案

7.1 问题:粘贴HTML内容格式丢失

解决方案

// 简单提取纯文本
handleHtmlPaste(html: string): string {
  // 移除HTML标签
  let text = html.replace(/<[^>]*>/g, '');
  
  // 转换HTML实体
  text = text.replace(/&nbsp;/g, ' ')
            .replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&amp;/g, '&')
            .replace(/&quot;/g, '"')
            .replace(/&#39;/g, "'");
  
  return text;
}

7.2 问题:输入法组合输入被中断

现象:中文输入时,拼音输入被意外中断。

解决方案

// 检测是否是组合输入
private isComposing: boolean = false;

aboutToIMEInput(value: RichEditorInsertValue) {
  // 如果是组合输入,不进行限制检查
  if (this.isComposing) {
    return true;
  }
  
  // 正常检查逻辑
  return this.handleBeforeInput(value);
}

// 监听输入法状态
onIMECompositionStart() {
  this.isComposing = true;
}

onIMECompositionEnd() {
  this.isComposing = false;
}

7.3 问题:性能问题

现象:内容很多时,每次输入都卡顿。

优化方案

// 1. 使用虚拟列表(如果支持)
// 2. 分块处理
// 3. 延迟计算
// 4. 使用Web Worker进行复杂计算

八、总结

通过完整的实现,我们解决了RichEditor输入限制的关键问题:

8.1 核心要点回顾

  1. 实时统计:通过onSelectionChange实时更新字符数

  2. 输入拦截:在aboutToIMEInput中提前判断和阻止

  3. 粘贴处理:覆盖默认粘贴行为,实现智能裁剪

  4. 准确计数:正确处理文本、图片、表情的字符计算

8.2 最佳实践

  1. 明确需求:确定字符计算规则(图片算几个字符?表情怎么算?)

  2. 用户体验:提供清晰的剩余字数提示

  3. 性能考虑:避免频繁计算,使用防抖优化

  4. 异常处理:考虑各种边界情况

  5. 测试覆盖:单元测试+集成测试确保质量

8.3 扩展思考

这个方案不仅适用于评论框,还可以扩展到:

  • 微博/朋友圈发布框

  • 商品描述编辑器

  • 博客/文章编辑器

  • 邮件客户端

  • 在线文档编辑器

改完之后,RichEditor的输入限制就完善多了。用户输入时有实时反馈,粘贴超长内容会自动裁剪,图片和表情也能正确计数。整个体验流畅自然,既满足了业务需求,又保证了用户体验。

记住这个原则:好的输入限制应该像贴心的助手,既提醒用户规则,又帮用户处理特殊情况,而不是生硬地拒绝。希望这个方案能帮助你在HarmonyOS应用开发中构建更好的富文本编辑体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐