DEV Community

HarmonyOS
HarmonyOS

Posted on

RichEditor @Mentions on HarmonyOS: BuilderSpan Mentions + Full Content Serialization

Read the original article:RichEditor @Mentions on HarmonyOS: BuilderSpan Mentions + Full Content Serialization

RichEditor @Mentions on HarmonyOS: BuilderSpan Mentions + Full Content Serialization

Requirement Description

Create an @mention experience with RichEditor where:

  • typing @ converts the next picked friend into a single removable unit (a builder span),
  • the editor’s entire content (including custom addBuilderSpan parts) can be retrieved/serialized.

Background Knowledge

  • RichEditor supports rich text editing, custom spans, and media insertion. Docs

Implementation Steps

  1. Intercept @ input with aboutToIMEInput. If user types @, insert a literal @ (as a TextSpan) and show your people-picker.
  2. On friend pick, look at the character before the caret; if it is the literal @ TextSpan, delete it so we won’t keep a stray @.
  3. Insert a builder span (addBuilderSpan) for @nickname with your desired style so it behaves like one unit (select/delete together).
  4. Track builder spans in an array parallel to editor spans so you can later serialize mentions along with normal text.
  5. Track current end position from onDidChange (rangeAfter.end) to easily fetch all spans in the editor.
  6. On input complete, update your text buffer and merge it with the builder-span metadata to export full content.

Code Snippet / Configuration

export interface User {
  id: string,
  avatar: ResourceStr
  nickname: string
}

interface ImageInfo {
  id: string
  title: string
  resource: ResourceStr
}

export interface RichEditorSpan {
  value?: string
  resourceValue?: ResourceStr
  type: 'text' | 'image' | 'builder'
  data?: User | ImageInfo
}

@Entry
@Component
struct Index {
  controller: RichEditorController = new RichEditorController();
  // End position of the selected content
  @State end: number = 0
  // Current input field content
  @State content: string = ''
  // Length marker (content count)
  @State flag: number = 0
  @State contentList: Array<string> = []
  // Friends list
  private friends: User[] = [
    { id: '0', avatar: $r('app.media.startIcon'), nickname: 'Test1' },
    { id: '1', avatar: $r('app.media.startIcon'), nickname: 'Test2' },
  ];
  private builderSpans: RichEditorSpan[] = [];
  // Friend click handler
  onAtFriendClick: (friend: User) => void = friend => {
    const controller = this.controller;
    const offset = controller.getCaretOffset();
    const range: RichEditorRange = { start: offset - 1, end: offset };
    if (offset !== 0 && (controller.getSpans(range)[0] as RichEditorTextSpanResult).value === '@') {
      controller.deleteSpans(range);
    }
    controller.addBuilderSpan(() => this.AtSpan(friend.nickname), {
      offset: controller.getCaretOffset()
    });
    this.setBuilderSpans(controller, friend);
    this.contentList.push(friend.nickname)
    this.content = ''
    // After inserting @mention, update current length
    this.flag = this.contentList.length
  }

  @Builder
  AtSpan(nickname: string) {
    Text(`@${nickname}`).fontColor(0xFF133667);
  }

  // Create builderSpan metadata
  setBuilderSpans(controller: RichEditorController, friend: User) {
    const builderSpan: RichEditorSpan = {
      value: `@${friend.nickname}`,
      data: friend,
      type: 'builder'
    };
    const range: RichEditorRange = { end: controller.getCaretOffset() };
    const index = this.getBuilderSpanCount(controller, range) - 1;
    this.builderSpans.splice(index, 0, builderSpan);
  }

  getBuilderSpanCount(controller: RichEditorController, range: RichEditorRange) {
    return controller.getSpans(range).reduce((count: number, span) => {
      return this.isBuilderSpan(span) ? count + 1 : count;
    }, 0);
  }

  isBuilderSpan(span: RichEditorImageSpanResult | RichEditorTextSpanResult): boolean {
    return !(span as RichEditorTextSpanResult).value &&
      !(span as RichEditorImageSpanResult).valueResourceStr?.toString().replaceAll(' ', '');
  }

  onAtButtonClick: (event?: ClickEvent) => void = event => {
    const controller = this.controller;
    controller.addTextSpan('@', { offset: controller.getCaretOffset() });
  }

  build() {
    Column() {
      Text("Input content: " + this.contentList.toString()).height('30%').width('98%').borderWidth(1)
      List({ space: 20 }) {
        ForEach(this.friends, (friend: User) => {
          ListItem() {
            Column() {
              Image(friend.avatar).width(40)
              Text(friend.nickname)
            }
            .onClick(() => this.onAtFriendClick(friend))
          }
        }, (friend: User) => friend.id)
      }
      .listDirection(Axis.Horizontal)
      .width('100%')
      .height('30%')
      .align(Alignment.Start)

      RichEditor({ controller: this.controller })
        .aboutToIMEInput((value: RichEditorInsertValue) => {
          if (value.insertValue === '@') {
            this.onAtButtonClick();
            return false;
          }
          return true;
        })
        .onDidChange((rangeBefore: TextRange, rangeAfter: TextRange) => {
          this.end = rangeAfter.end ? rangeAfter.end : 0
        })
        .onIMEInputComplete((value: RichEditorTextSpanResult) => {
          // Input field content
          this.content = value.value
          // On first typing, push the text into contentList
          if (this.contentList.length === this.flag) {
            this.contentList.push(this.content)
          } else if (this.contentList.length - 1 === this.flag) {
            this.contentList[this.contentList.length-1] = this.content
          }
        })
        .width("98%")
        .height("30%")
        .borderWidth(1)
    }
    .width("100%")
    .height("100%")
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Results

  • Verified that @nickname is a single selectable/deletable unit, and dumping spans returns a mixed list of text | image | builder entries that includes the mention metadata.

10.gif

Limitations or Considerations

  • Long names and caret height: If a builder span wraps to multiple lines and the caret height increases undesirably, you can switch to a simple addTextSpan('@' + nickname) for that case (trades unit deletion for simpler line metrics).
  • Version / Tools: Works with API Version 20 Release+, HarmonyOS 6.0.0 Release SDK+, DevEco Studio 6.0.0 Release+.
  • Index mapping: We map builder spans by encounter order. If you programmatically rearrange content, recompute indices.

Related Documents or Links

Written by Bunyamin Eymen Alagoz

Top comments (0)