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
addBuilderSpanparts) can be retrieved/serialized.
Background Knowledge
- RichEditor supports rich text editing, custom spans, and media insertion. Docs
Implementation Steps
-
Intercept
@input withaboutToIMEInput. If user types@, insert a literal@(as aTextSpan) and show your people-picker. -
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@. -
Insert a builder span (
addBuilderSpan) for@nicknamewith your desired style so it behaves like one unit (select/delete together). - Track builder spans in an array parallel to editor spans so you can later serialize mentions along with normal text.
-
Track current end position from
onDidChange(rangeAfter.end) to easily fetch all spans in the editor. - 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%")
}
}
Test Results
- Verified that
@nicknameis a single selectable/deletable unit, and dumping spans returns a mixed list oftext | image | builderentries that includes the mention metadata.
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.

Top comments (0)