便笺里有一行标题和一段说明,改粗标题之后,底部统计却多出一个“内容块”。这样的变化不一定是错误:富文本编辑器管理的是带样式的跨度,业务想表达的却可能是标题与正文。把两者一开始就当成同一种对象,后面加入选区、段落和导出时就会反复修补。本系列先建立一个小型排版便笺,让编辑能力和业务含义各自有清楚的位置。

RichEditor控制器与文本跨度的技术封面

先确定一个足够小的编辑范围

工程名为 NoteTypeset,使用 Stage 模型和 API 12 编译基线。当前页面只编辑文字,不访问网络、相册或文件,因此不需要增加权限声明。新建 Empty Ability 工程,保留模板的 EntryAbility 与 pages/Index 入口;新增公共种子文件,再替换页面即可。后续六篇持续使用这个工程名称,实验面板与正式文档的控制器会明确区分。

这里选择 RichEditor,是因为用户需要直接把光标放进内容中,选择局部文字并修改格式。只展示图文时,Text 与 Span 已经能承担许多工作;需要显示大段网页内容时,也不能因为名字相似就把 RichEditor 当成浏览器。编辑组件的价值在于输入、选区与样式操作,它并不自动提供一份适合业务长期保存的文档协议。

页面内只有两个初始内容片段:带换行的“本周记录”,以及“完成草稿,保留修改空间。”。它们是便于复现的固定内容,没有隐藏资源或远程数据。读者先观察它们怎样进入编辑器,再亲手改变内容。初始化完成以后,文本的实时状态由编辑器维护,页面上的状态变量只负责显示操作提示,不再保存一份随时覆盖输入的副本。

这一限制能够避免一个很难察觉的问题:用状态数组表达初始内容,又在每次重绘时根据该数组重新调用插入接口,会把用户刚输入的文字挤掉或重复加入。声明式界面重建不是文档初始化信号。按钮颜色变化、提示文字更新、窗口重新布局,都没有资格重新写入文档。初始化必须绑定到组件就绪,恢复样例则必须来自用户的明确操作。

编辑器的可编辑状态与页面是否可见也不是一回事。页面还在栈中但暂时被遮住时,文档可能仍然存在;组件真正销毁后,原控制器则不能继续承担新的操作。本文没有把每次显示当成重新创建,只在就绪回调中初始化。这种做法使返回页面与恢复样例保持不同含义,也为后续结束编辑状态留下位置。

把初始内容放进可复用函数

在 entry/src/main/ets/common/NoteSeed.ets 写入下面的函数。它接收控制器,不创建控制器;这样同一组初始内容能够用于不同实验页,而编辑器的实例归属仍由页面决定。函数先清空再加入,所以它的名字表达的是“恢复”,而不是一般意义上的追加。

export function fillSeed(controller: RichEditorController): void {
  controller.deleteSpans();
  controller.addTextSpan('本周记录\n', {
    style: {
      fontSize: 24,
      fontWeight: FontWeight.Bold,
      fontColor: '#34466D'
    }
  });
  controller.addTextSpan('完成草稿,保留修改空间。', {
    style: { fontSize: 18, fontColor: '#263246' }
  });
}

两个插入调用分别携带必要样式,避免正文继承标题的粗体和字号。标题末尾的换行是内容的一部分,不靠组件外边距构造段落。这样,后文查询段落边界时有可解释的起点;把标题和正文摆在两个独立 Text 组件里虽然能得到近似外观,却不能形成同一个可选择、可编辑的文档。

控制器返回的跨度索引属于当前编辑结构,不是永久业务编号。局部改色可能拆分原来的跨度,插入与删除也可能改变后续索引。因此不能把“第一个跨度”长久绑定成标题,也不能把某次返回的索引直接当作数据库主键。业务若将来需要稳定的段落标识,应在独立模型中定义,而不是从当前排版碎片中猜出来。

函数没有把整个组件放进全局对象,也没有引用页面的状态变量。全局控制器一旦被两个编辑区共用,调用看似相同,实际操作目标却难以判断。一个页面中的一个编辑区对应一个明确控制器,这个约束比先建立复杂的编辑服务更有用。当前函数只使用就绪后的同步操作,异常定位也因此能从调用时机开始。

DevEco Studio中查看NoteSeed文本初始化代码

图 2:编辑器中的种子函数与右侧便笺页面对应;标题样式、正文和恢复按钮采用同一套定义。

页面只发命令,不在重建时补写内容

将 pages/Index.ets 替换为下列页面。控制器是普通实例字段,ready 标志用来阻止组件尚未就绪时的操作。底部的范围列表来自一次显式读取,不会在 build 中反复查询控制器。这样界面描述、用户动作和文档读写形成了三条清楚的路径。

import { fillSeed } from '../common/NoteSeed';

@Entry
@Component
struct Index {
  private controller: RichEditorController = new RichEditorController();
  private ready: boolean = false;
  @State private status: string = '等待编辑器';
  @State private detail: string = '点击查看跨度结构';

  aboutToDisappear(): void { this.ready = false; }

  private inspect(): void {
    if (!this.ready) { return; }
    const spans = this.controller.getSpans();
    const ranges: string[] = [];
    for (const span of spans) {
      const range = span.spanPosition.spanRange;
      ranges.push(`[${range[0]}, ${range[1]})`);
    }
    this.detail = ranges.length > 0 ? ranges.join(' · ') : '文档为空';
    this.status = `当前返回 ${spans.length} 个跨度`;
  }

  private reset(): void {
    if (!this.ready) { return; }
    fillSeed(this.controller);
    this.status = '样例已载入';
    this.detail = '点击查看跨度结构';
  }

  build() {
    Column({ space: 14 }) {
      Text('排版便笺').fontSize(26).fontWeight(FontWeight.Bold)
        .fontColor('#263246')
      Text('控制器与内容分别管理').fontSize(14).fontColor('#68758A')
      RichEditor({ controller: this.controller })
        .width('100%').height(240).padding(16)
        .backgroundColor(Color.White).borderRadius(16)
        .onReady(() => { this.ready = true; this.reset(); })
      Row({ space: 12 }) {
        Button('查看跨度').onClick(() => { this.inspect(); })
        Button('恢复样例').onClick(() => { this.reset(); })
      }
      Text(this.status).fontSize(16).fontColor('#4F5E8C')
      Text(this.detail).fontSize(14).fontColor('#68758A')
        .width('100%').padding(16).backgroundColor('#E8ECF4')
        .borderRadius(12)
    }.width('100%').height('100%').padding({ left: 24, right: 24, top: 40 })
      .backgroundColor('#F6F4EF')
  }
}

恢复按钮会清空当前便笺,这是它公开表达的行为;真实产品可以在有未保存内容时增加确认。不能把这段函数直接挂在页面每次出现的回调上,否则从另一个页面返回也可能丢失编辑。组件就绪负责初始建立,用户按钮负责主动重置,页面销毁仅关闭本实例的操作入口。销毁回调不再写入任何状态变量。

查看跨度时,页面读取每个结果中的全局范围,再把范围转成说明文字。这里没有展示某个固定的跨度数量,因为编辑器可以因换行和样式操作形成不同的内部切分。统计的用途是理解当前结构,而不是宣称“一段业务内容必须只有一个跨度”。只要连续文本和样式正确,跨度数量变化本身不足以判断功能失效。

按钮没有绕过 ready 标志,公共函数也不替调用方猜测组件是否已经挂载。这种约束让错误入口更容易暴露:如果界面还显示等待编辑器,先检查组件是否真正创建和触发就绪,再去检查字体或业务数据。对于没有网络请求、定时器和外部订阅的页面,不需要为了形式完整额外建立取消任务或资源释放队列。

页面控制器编辑内容与提示状态的职责图

图 3:页面持有控制器,控制器操作编辑内容;提示状态只描述操作结果,不反向覆盖文档。

范围的终点采用右边界表达,底部用半开区间的写法减少歧义。两个相邻范围共享一个边界,并不表示那一个位置被编辑了两次。当前显示只是调试阅读辅助,不能作为用户看到的第几个字的承诺;包含复合字符或非文本内容时,更应使用组件提供的选区和跨度信息,不能凭屏幕上数出来的字形数量反推。

沿着可观察结果验证最小闭环

首次进入页面,应看到深色粗体标题与普通正文,提示为样例已载入。点击查看跨度后,底部出现由控制器返回的范围。随后把光标放在正文中输入几个字,再点击查看,范围应随当前内容变化。这个过程验证的是读取是否来自编辑器当前状态,而不是读取一份已经过时的初始化数组。

接下来只修改底部提示相关的状态,观察文档是否保持。若正文被重新插入,说明文档操作进入了重建路径;若只有显示范围变化,说明读写边界仍然独立。再把内容全部删除,点击查看应得到文档为空;恢复样例后重新出现初始两段内容。空内容是合法编辑结果,不能被自动恢复逻辑当作加载失败。

还应检查重复恢复:连续点击两次,页面不应该叠出两个标题。这是先删除后添加的直接结果。与之相对,在普通追加按钮中使用同一函数就会误删原有文档,因此不能为了减少代码而把恢复和追加合并成一个没有语义的写入方法。方法名称应该让调用方看得出它是否替换已有内容。

测试记录中可以保留进入、输入、删除、恢复四组操作与观察结果。编辑器当前没有定义字体安装、跨设备同步和文件持久化,这些内容不能从页面正常显示推导出来。若需要比较不同设备的换行,应先固定字体缩放、编辑区宽度和种子文本,再讨论差异;不能把两张尺寸不同的页面当作排版算法对比。

页面标题编辑区操作按钮和提示区的组件拆解

图 4:初始化、读取和重置各有明确触发条件;界面重建不执行文档写入。

检查样式时,应分别观察已有文本和随后输入的文本。种子函数设置的是已插入跨度的样式,不能由此断言以后每一个输入位置都会保持同样字号。光标位置、相邻跨度以及专门的输入样式设置会影响新内容。首篇只验证固定内容的建立,不提前把输入格式继承当成业务默认策略,后续格式工具再按明确命令处理。

组件尺寸也属于复现条件。本文为编辑区给出固定高度,便于把同一内容放在稳定视口中;内容超过可见区域后,阅读与滚动问题需要单独观察。不要为了让截图装下所有文字,在业务模型里删掉超出区域的部分。编辑内容的长度与窗口能显示多少行是两种约束,界面缩放不会自动改变文档。

把跨度作为编辑事实,而非业务身份

这个最小工程已经具备可编辑内容、显式恢复和结构读取,但它刻意没有建立标题数据库或自动保存协议。跨度描述的是当前文本怎样被切分和排版;业务身份描述的是内容在长期使用中的含义。两种信息可以有关联,却不能因为第一次加载时碰巧顺序相同,就把它们永久绑定。

当后续加入局部样式时,读者会看到同一行文字被分成不同跨度。理解这一点,才能正确解释选区只覆盖某个跨度的一部分,以及样式操作为什么不该通过删除整段再重建来完成。下一篇直接使用控制器提供的选区范围,把操作约束在当前选择的内容中,同时避免把可见字数、字符串长度和文档位置混成一个数字。

对小便笺而言,最重要的扩展点不是增加更多按钮,而是保留清楚的内容入口。种子函数只负责恢复,控制器只在自己的组件就绪后使用,状态变量只承担界面说明。后续增加的功能只要遵守这些边界,就不必通过猜测某次重绘会不会重置文本来维持正确性。

官方资料

Logo

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

更多推荐