【高心星出品】

仿朋友圈富文本编辑器开发

概述

在移动应用开发中,富文本编辑器是社交、评论、笔记等场景的核心组件。ArkUI提供了RichEditor组件,支持图文混排和交互式文本编辑。

本文旨在探讨如何使用RichEditor组件,在内容发布场景中实现自定义表情、@好友、添加话题等功能,并提供示例代码详细拆解细节逻辑,如@好友如何被视为一个整体,编辑器中内容如何获取并归一化处理等。

请添加图片描述

实现原理

RichEditor组件内容管理方式选型

RichEditor组件提供两套内容管理接口,方式一使用RichEditor(options: RichEditorStyledStringOptions)接口创建基于属性字符串(StyledString/MutableStyledString)进行内容管理的组件,方式二使用RichEditor(value: RichEditorOptions)接口创建基于Span进行内容管理的组件。在本文内容发布场景中需要频繁进行输入文字、添加表情、@好友、删除等操作,且无需复杂的样式操作,选择使用方式二基于Span进行内容管理更为合适。

  • 方式一:基于属性字符串管理
    • 进行内容样式更新,更加灵活便捷。
    • 序列化困难,需手动提取属性存储,增加持久化逻辑复杂度。
    • 动态内容维护成本高,不适合频繁增删操作。
  • 方式二:基于Span管理
    • 使用便捷,通过直接操作独立的Span来动态构建和修改内容。
    • 支持增量式操作,适合需要频繁交互、动态修改(如插入表情、@好友)的场景。

添加不同类型内容的方式选型

编辑区域支持输入文字、表情、@好友等内容。

  • 使用系统键盘可输入文字,通过RichEditorController.setTypingStyle()方法可以设置默认的文本样式。

  • 添加自定义表情图片可使用RichEditorController.addImageSpan()方法实现。

  • @好友可使用RichEditorController.addTextSpan()
    RichEditorController.addBuilderSpan()
    两种方法实现。

    通过如下对比分析可知,使用addBuilderSpan()方法实现更为便捷,但是无法规避折行问题,具体实现可参考评论回复弹窗。本文使用addTextSpan()方法实现,提供@好友等自定义内容需要折行显示的开发指导。

    使用addBuilderSpan()方法 使用addTextSpan()方法
    内容长度 默认作为一个整体,长度视为一个文字 以文本形式添加,长度为文本长度
    光标与删除逻辑 默认作为一个整体,无需额外处理 需手动处理光标变化、删除规则
    折行与显示规则 长度超一行:光标高度随builderSpan高度自动调整长度不超一行:无法折行,触达边界后自动换行 与普通文本逻辑一致,支持正常折行显示
    数据获取与维护 无法获取builderSpan中的内容 可获取textSpan内容,但额外携带数据需自行维护

为了方便表述,对通过addTextSpan()方法和addBuilderSpan()方法添加到编辑区域的内容分别命名为textSpan和builderSpan。

维护输入内容

由上述选型可知,使用addTextSpan()方法实现@好友、添加话题等自定义内容时,需手动处理光标变化、删除内容和携带数据的逻辑。

需要定义一个数据结构能将自定义的textSpan维护起来,value用于存储显示的文字内容,spanRange记录内容在编辑区的起始位置和结束位置,以便查找,type用于区分不同类型(例如是@好友还是添加的话题)。data用于携带结构化数据,如好友id、头像等。

interface TextSpan {
  value: string;
  spanRange: [number, number];
  type: string;
  data: ESObject;
}

代码逻辑走读:

  1. 定义了一个接口TextSpan,该接口用于描述一个文本范围对象。
  2. 接口包含四个属性:value(字符串类型)、spanRange(数字数组类型)、type(字符串类型)、data(ESObject类型)。
  3. spanRange属性是一个长度为2的数组,表示文本的起始和结束位置。
  4. type属性用于标识文本的类型,具体类型由调用者定义。
  5. data属性用于存储与文本相关的附加数据,类型为ESObject。

通过维护一个textSpans数组记录所有自定义的textSpan,涉及编辑区域添加或删除内容的操作都需要去更新当前光标后所有textSpan的位置信息(spanRange字段)。

private textSpans: TextSpan[] = [];
// ...
// Update the textSpans range that come after the current offset
updateTextSpans(insertOffset: number, insertLength: number) {
  this.textSpans.forEach(textSpan => {
    if (textSpan.spanRange[0] >= insertOffset) {
      textSpan.spanRange[0] += insertLength;
      textSpan.spanRange[1] += insertLength;
    }
  })
}

代码逻辑走读:

  1. 方法定义updateTextSpans方法接受两个参数:insertOffset(插入偏移量)和insertLength(插入长度)。
  2. 遍历textSpans:使用forEach方法遍历textSpans数组中的每个textSpan对象。
  3. 条件判断:对于每个textSpan,检查其spanRange的起始位置是否大于或等于insertOffset
  4. 范围更新:如果条件满足,则将textSpanspanRange的起始和结束位置都加上insertLength,以反映插入操作后的新范围。

添加自定义表情

场景描述

点击下方表情按钮,系统键盘切换为表情面板。点击表情图标,会在编辑区域光标后方添加对应的表情内容。

在这里插入图片描述

开发步骤

  1. 使用

    customKeyboard(value: CustomBuilder, options?: KeyboardOptions)

    属性给编辑区域绑定自定义键盘,通过状态变量isEmojiKeyboard控制系统键盘和自定义键盘(表情面板)的切换。并设置

    KeyboardOptions

    参数中的supportAvoidance属性值为true,使自定义键盘也支持避让功能。

    @Link isEmojiKeyboard: boolean;
    // ...
    build() {
      RichEditor({
        controller: this.richEditorController
      })
        .customKeyboard(this.isEmojiKeyboard ? this.EmojiKeyboard() : undefined, {
          supportAvoidance: true
        })
        // ...
    }
    

    代码逻辑走读:

    1. 属性定义:代码首先定义了一个名为isEmojiKeyboard的布尔属性,用于控制是否使用emoji键盘。
    2. 构建富文本编辑器:在build方法中,创建了一个RichEditor实例,并传入了一个控制器richEditorController
    3. 动态键盘设置:通过调用customKeyboard方法,根据isEmojiKeyboard的值决定是否使用自定义键盘。如果为true,则使用EmojiKeyboard;否则,不使用。
    4. 键盘配置:在设置自定义键盘时,通过supportAvoidance: true配置,支持避免键盘遮挡文本内容。
  2. 在表情面板中点击表情,可通过

    addImageSpan()

    方法在编辑区域添加表情图片,默认在内容最后方插入。

    通过设置offset属性为当前光标位置,使表情在正确位置插入。

    当前光标位置可通过getCaretOffset()方法获取。后文类似的插入操作都遵循此规则。

    addImageSpan(value: ResourceStr) {
      const controller = this.richEditorController;
      const curOffset = controller.getCaretOffset();
      controller.addImageSpan(value, {
        offset: curOffset,
        imageStyle: {
          size: [16, 16]
        }
      });
      // Update the textSpans spanRange that come after the current offset
      this.updateTextSpans(curOffset, 1);
    }
    

    代码逻辑走读:

    1. 方法定义:定义了一个名为 addImageSpan的方法,接受一个参数 value,类型为 ResourceStr,表示要添加的图像资源。
    2. 获取控制器:从当前对象中获取 richEditorController,用于操作富文本编辑器。
    3. 获取光标位置:调用 controller.getCaretOffset()获取当前光标的位置,并将其存储在 curOffset变量中。
    4. 添加图像:调用 controller.addImageSpan(value, { offset: curOffset, imageStyle: { size: [16, 16] } })在光标位置添加一个图像,图像大小为 16x16。
    5. 更新文本范围:调用 this.updateTextSpans(curOffset, 1)更新光标位置之后的文本范围。

@好友

场景描述

点击下方工具栏@图标或使用系统键盘输入@字符,会跳转到好友列表页面,选择对应好友将自动跳转回编辑页面并在编辑区域添加对应的@好友内容。

添加话题、标题与@好友逻辑一致。

在这里插入图片描述

开发步骤

  • 点击工具栏@图标跳转好友列表
  1. 点击下方工具栏@图标,跳转到好友列表页面,选择好友后,使用

    addTextSpan()

    方法将@好友作为文本内容添加到编辑区域。通过offset属性和style属性分别控制插入的位置和样式。可通过添加一个空字符和其他内容做视觉上的分割。

    addTextSpan(value: string, type: string, data: ESObject, style: RichEditorTextStyle) {
      // ...
      const controller = this.richEditorController;
      const curOffset = controller.getCaretOffset();
      controller.addTextSpan(value, {
        offset: curOffset,
        style
      });
      const splitChar = ' ';
      controller.addTextSpan(splitChar, {
        offset: controller.getCaretOffset()
      });
      // ...
    }
    

    代码逻辑走读:

    1. 方法定义:定义了一个名为 addTextSpan的方法,接受四个参数:value(文本内容)、type(文本类型)、data(附加数据)和 style(样式)。
    2. 获取控制器:通过 this.richEditorController获取富文本编辑器的控制器。
    3. 获取当前光标位置:使用 controller.getCaretOffset()获取当前光标的位置,并将其存储在 curOffset中。
    4. 添加文本:调用 controller.addTextSpan方法,在当前光标位置添加文本 value,并指定样式 style和偏移量 offsetcurOffset
    5. 添加分隔符:定义分隔符 splitChar为一个空格字符,然后在当前光标位置添加分隔符,不指定样式。
    6. 结束:方法结束,不返回任何值。
  2. 维护自定义textSpan数据。更新在当前光标后面所有的自定义textSpan的位置信息,起始位置往后移动的距离为新添加内容的长度,并在textSpans数组中添加该自定义内容textSpan的信息。

    addTextSpan(value: string, type: string, data: ESObject, style: RichEditorTextStyle) {
      // ...
      this.updateTextSpans(curOffset, value.length + splitChar.length);
      this.textSpans.push({
        value,
        type,
        data,
        spanRange: [curOffset, curOffset + value.length],
      })
    }
    

    代码逻辑走读:

    1. 方法定义:定义了一个名为 addTextSpan的方法,该方法接收四个参数:value(文本内容)、type(文本类型)、data(相关数据)和 style(文本样式)。
    2. 注释部分:代码中包含注释,表明这部分内容被省略,实际代码可能会处理一些逻辑。
    3. 更新文本偏移量:调用 this.updateTextSpans方法,传入当前偏移量 curOffset和文本长度加上分隔符长度,用于更新文本片段的偏移量。
    4. 添加文本片段:将新的文本片段信息(包括 valuetypedataspanRange)添加到 textSpans数组中。spanRange表示文本片段在文本中的起始和结束位置。
  3. 在编辑区域添加了@好友内容后,在其前后输入的文字会拥有@好友内容的样式,并与其合并成一个textSpan。

    在RichEditor组件onReady()事件回调函数中,为键盘输入内容设置默认样式,这样就能与@好友等自定义textSpan内容隔离开。

    RichEditor({
      controller: this.richEditorController
    })
      // ...
      .onReady(() => {
        this.richEditorController.setTypingStyle({
          fontColor: Color.Black,
        })
      })
    

    代码逻辑走读:

    1. 创建一个富文本编辑器组件,使用RichEditor构造函数,并传入一个控制器this.richEditorController
    2. 通过链式调用,设置组件的onReady事件处理函数。
    3. onReady事件触发时(即富文本编辑器组件准备好后),调用控制器的setTypingStyle方法。
    4. setTypingStyle方法的参数是一个对象,其中指定了输入样式的fontColor属性为黑色。
  • 键盘输入@符号跳转好友列表
  1. 通过键盘输入@符号的方式跳转好友列表,键盘默认行为会先在编辑区添加@符号,在选择好友后需要先删除默认行为添加@符号,再将@好友作为整体通过addTextSpan()方法添加。

    通过状态变量isKeyboardTriggered标志是否为键盘输入触发。在aboutToIMEInput()键盘输入事件的回调函数中,判断插入字符是@,则更新标志位为true,并跳转联系人页面。在添加@好友内容时如果是键盘输入触发,则删除光标前一个字符,并将标志位重置为false。

    @State isKeyboardTriggered: boolean = false;
    // ...
    deletePrevChar() {
      const controller = this.richEditorController;
      const offset = controller.getCaretOffset();
      const range: RichEditorRange = { start: offset - 1, end: offset };
      controller.deleteSpans(range);
    }
    // ...
    addTextSpan(value: string, type: string, data: ESObject, style: RichEditorTextStyle) {
      if (this.isKeyboardTriggered) {
        this.deletePrevChar()
        this.isKeyboardTriggered = false;
      }
      // ...
    }
    // ...
    aboutToIMEInput: (value: RichEditorInsertValue) => boolean = value => {
      if (value.insertValue === '@') {
        this.isKeyboardTriggered = true;
        this.getUIContext().getRouter().pushUrl({ url: 'pages/ContactListPage' });
        // ...
      }
      return true;
    }
    

    代码逻辑走读:

    1. 状态初始化
      • 使用 @State初始化一个布尔状态 isKeyboardTriggered,用于跟踪键盘输入是否被触发。
    2. 删除字符功能
      • deletePrevChar方法获取当前光标位置,并删除当前光标前的字符。
    3. 添加文本和样式
      • addTextSpan方法在插入文本时,如果键盘被触发,会先删除前一个字符,然后重置键盘触发状态。
    4. 键盘输入事件处理
      • aboutToIMEInput方法监听键盘输入事件,当输入的值为’@'时,设置键盘触发状态为真,并导航到联系人列表页面。
  2. 后续步骤与点击工具栏@图标跳转好友列表方式的开发步骤中的1、2、3步一致。

处理光标位置

场景描述

光标不可落入@好友文本的内部。当用户点击或选中@好友这种自定义内容时,光标应自动跳转到内容的开始或结束位置。

请添加图片描述

开发步骤

  1. 判断当前光标位置是否在自定义内容的textSpan中间,是则根据就近原则返回textSpan的起始位置或结束位置,否则返回当前光标位置。

    snapCaretToTextSpanBoundary(caretOffset: number, type?: 'start' | 'end') {
      const textSpan = this.textSpans.find(textSpan => {
        return caretOffset > textSpan.spanRange[0] && caretOffset < textSpan.spanRange[1];
      });
      if (!textSpan) return caretOffset;
      if (type === 'start') return textSpan.spanRange[0];
      if (type === 'end') return textSpan.spanRange[1];
      const disToStart = caretOffset - textSpan.spanRange[0];
      const disToEnd = textSpan.spanRange[1] - caretOffset;
      if (disToStart <= disToEnd) return textSpan.spanRange[0];
      return textSpan.spanRange[1];
    }
    

    代码逻辑走读:

    1. 方法定义:定义了一个方法snapCaretToTextSpanBoundary,接受两个参数:caretOffset(光标偏移量)和可选的type(指定位置类型,可以是’start’或’end’)。
    2. 查找文本范围:使用find方法在this.textSpans数组中查找一个textSpan,其spanRange包含caretOffset
    3. 判断文本范围是否存在:如果textSpan不存在,直接返回caretOffset
    4. 根据类型返回特定位置
      • 如果type为’start’,返回textSpan.spanRange[0](文本范围的起始位置)。
      • 如果type为’end’,返回textSpan.spanRange[1](文本范围的结束位置)。
    5. 计算距离
      • 计算光标距离textSpan.spanRange[0]的距离disToStart
      • 计算光标距离textSpan.spanRange[1]的距离disToEnd
    6. 选择最近的位置:如果disToStart小于等于disToEnd,返回textSpan.spanRange[0];否则返回textSpan.spanRange[1]
  2. 当用户点击@好友内容时,在onSelectionChange()事件的回调函数中重新计算并使用setCaretOffset()设置光标位置。

    onSelectionChange: (range: RichEditorRange) => void = range => {
      // When start and end values are the same, it indicates a cursor position change triggered by a click, with no selected area.
      if (range.start === range.end) {
        const targetCaretOffset = this.snapCaretToTextSpanBoundary(range.start!);
        this.richEditorController.setCaretOffset(targetCaretOffset);
      }
    }
    

    代码逻辑走读:

    1. 函数定义:定义了一个名为 onSelectionChange的函数,该函数接受一个参数 range,类型为 RichEditorRange,返回类型为 void

    2. 条件判断:通过 if语句判断 range.start是否等于 range.end。如果相等,表示没有选择区域。

    3. 光标调整

      • 调用 this.snapCaretToTextSpanBoundary(range.start!)方法,将光标位置调整到最近的文本片段边界,并将结果存储在 targetCaretOffset变量中。
      • 调用 this.richEditorController.setCaretOffset(targetCaretOffset)方法,将光标位置设置为 targetCaretOffset
  3. 当选中内容发生变化时,在onSelect()事件的回调函数中获取选中内容的起始位置和结束位置。

    • 当它们处于同一个自定义内容的textSpan中间时,更新两个光标的位置到该textSpan的起始位置和结束为止。
    • 否则,更新两个光标的位置到各自就近的textSpan边缘。

    最后通过setSelection()方法设置计算后的选中区域。

    onSelect: (selection: RichEditorSelection) => void = richEditorSelection => {
      const caretStart = richEditorSelection.selection[0];
      const caretEnd = richEditorSelection.selection[1];
      // Determine if the selected content lies within a single textSpan.
      // If it does, adjust the selection range to the start and end of that textSpan.
      const textSpan = this.textSpans.find(textSpan => {
        return caretStart >= textSpan.spanRange[0] && caretEnd <= textSpan.spanRange[1];
      });
      if (textSpan) {
        this.richEditorController.setSelection(textSpan.spanRange[0], textSpan.spanRange[1]);
        return;
      }
      // Both values being -1 indicates clear selection operation.
      if (caretStart === -1 && caretEnd ===  -1) {
        return
      }
      const selectionStart = this.snapCaretToTextSpanBoundary(caretStart);
      const selectionEnd = this.snapCaretToTextSpanBoundary(caretEnd);
      this.richEditorController.setSelection(selectionStart, selectionEnd);
    }
    

    代码逻辑走读:

    1. 获取选择范围:函数首先获取用户选择的起始和结束位置,存储在caretStartcaretEnd变量中。
    2. 查找文本段落:通过find方法查找是否存在一个文本段落,该段落的范围包含用户选择的起始和结束位置。
    3. 调整选择范围:如果找到了符合条件的文本段落,则将选择范围调整为该文本段落的起始和结束位置,并返回。
    4. 处理无选择情况:如果用户选择的起始和结束位置都为-1,表示没有选择内容,函数直接返回。
    5. 调整选择边界:如果没有找到符合条件的文本段落,函数会将选择范围的起始和结束位置调整到最近的文本段落边界,并设置新的选择范围。

删除内容

场景描述

点击软键盘删除按钮,光标前待删除的是@好友等自定义内容时,则作为整体删除。其余内容删除时无需额外处理。

在这里插入图片描述

开发步骤

  1. 在aboutToDelete()事件的回调函数中,获取将要删除内容的起始位置和结束位置。
  2. 若起始位置在自定义的textSpan中间,则更新起始位置为该textSpan的起始位置。若结束位置在自定义的textSpan中间,则更新结束位置为该textSpan的结束位置。
  3. 使用deleteSpans()方法删除编辑区域中的内容。
  4. 更新自行维护的textSpans数据,删除起始位置和结束位置中间所有的textSpan数据,并更新在删除内容后所有的自定义textSpan的位置信息。
aboutToDelete: (value: RichEditorDeleteValue) => boolean = deleteValue => {
  const start = deleteValue.offset;
  const end = start + deleteValue.length;
  const snapStart = this.snapCaretToTextSpanBoundary(start, 'start');
  const snapEnd = this.snapCaretToTextSpanBoundary(end, 'end');
  this.richEditorController.deleteSpans({
    start: snapStart,
    end: snapEnd
  });
  // delete all textSpans in selection
  this.textSpans = this.textSpans.filter(ts => {
    const isInRange = ts.spanRange[0] >= snapStart && ts.spanRange[1] <= snapEnd
    return !isInRange;
  });
  this.updateTextSpans(snapStart, snapStart - snapEnd);
  return false;
}

代码逻辑走读:

  1. 参数解析:函数aboutToDelete接收一个参数deleteValue,该参数包含了删除操作的相关信息,如删除的起始位置和长度。
  2. 边界调整:使用snapCaretToTextSpanBoundary方法对删除的起始和结束位置进行调整,确保它们位于文本片段的边界上。
  3. 删除操作:调用richEditorController.deleteSpans方法,根据调整后的起始和结束位置删除相应的文本片段。
  4. 过滤文本片段:遍历textSpans列表,过滤掉在删除范围内的文本片段,并更新列表。
  5. 更新文本片段:调用updateTextSpans方法,更新文本片段列表,确保其反映最新的文本状态。
  6. 返回值:函数返回false,表示删除操作未被完全处理,可能需要进一步的逻辑来完成删除。

获取内容

场景描述

发布内容时,需要获取编辑区域的文本内容,同时还需要获取带有结构化信息(如好友的id)的数据,用于提交到服务器或持久化存储。

开发步骤

  1. 实际开发中编辑区域不同类型的内容往往需要一种统一的数据结构来表达,方便传输和存储。这里定义为CustomSpan,其中value表示文本内容,resourceValue表示表情图片资源,type用于区分不同类型的textSpan,例如是@好友还是添加的话题,data用于存储携带的信息,例如好友id等。

    export interface CustomSpan {
      value?: string;
      resourceValue?: ResourceStr;
      type?: string;
      data?: ESObject;
    }
    

    代码逻辑走读:

    1. 定义了一个接口CustomSpan,该接口用于描述一个对象的结构。
    2. 接口中包含四个可选属性:
      • value:可能为字符串类型。
      • resourceValue:类型为ResourceStr,具体类型未在此代码片段中定义。
      • type:可能为字符串类型。
      • data:可能为ESObject类型,具体类型未在此代码片段中定义。
  2. RichEditor组件提供getSpans()方法来获取内容,但是例如@好友中一些结构化信息仍需要根据位置信息去手动维护的textSpans数组中去查找。最后将getSpans()方法获取的RichEditorTextSpanResult和RichEditorImageSpanResult转换成CustomSpan。

    getData(): CustomSpan[] {
      const customSpans = this.richEditorController.getSpans().map(span => {
        const textSpan = span as RichEditorTextSpanResult;
        const imageSpan = span as RichEditorImageSpanResult;
        // is imageSpan
        if (!textSpan.value) {
          return { resourceValue: imageSpan.valueResourceStr } as CustomSpan;
        }
        // is textSpan
        const customTextSpan = this.textSpans.find(customTextSpan => {
          return this.isTheSameRange(customTextSpan.spanRange, textSpan.spanPosition.spanRange);
        })
        if (!customTextSpan) {
          return { value: textSpan.value } as CustomSpan;
        }
        return {
          value: customTextSpan.value,
          type: customTextSpan.type,
          data: customTextSpan.data
        } as CustomSpan;
      });
      return customSpans;
    }
    
    1. 获取跨度信息
      • 使用 this.richEditorController.getSpans()获取所有文本和图像的跨度信息,并将其映射为一个新的数组 customSpans
    2. 判断跨度类型
      • 对于每个跨度 span,首先尝试将其转换为 textSpan
      • 如果 textSpan.valuefalse(即为空),则认为该跨度为图像类型,并返回一个包含 valueResourceStrCustomSpan对象。
    3. 处理文本跨度
      • 如果 textSpan.value不为空,则尝试在 this.textSpans中找到与当前 textSpan范围相同的自定义文本跨度 customTextSpan
      • 如果找不到匹配的 customTextSpan,则返回一个包含 textSpan.value的基础 CustomSpan对象。
    4. 返回自定义跨度
      • 如果找到匹配的 customTextSpan,则返回一个包含 customTextSpan的值、类型和数据信息的 CustomSpan对象。
    5. 返回结果
      • 最终返回包含所有自定义跨度的数组 customSpans
  3. 图中内容通过getData()方法生成的数据序列化后的数据如下,如何与服务端交互或使用这些数据,根据业务需求调整即可。

    在这里插入图片描述

    [{
      "value": "Hello"
    }, {
      "resourceValue": "resource:///emoji_3.png"
    }, {
      "value": "@阿飞",
      "type": "contact",
      "data": {
        "imgName": "app.media.ic_comm_pic1",
        "name": "阿飞"
      }
    }, {
      "value": " "
    }, {
      "value": "#众测主题赛#",
      "type": "topic",
      "data": {
        "topicId": "1",
        "title": "众测主题赛"
    }, {
      "value": " "
    }]
    
    
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐