排版便笺已经有一个强调按钮,再增加长按菜单时,最容易出现的实现是把那段格式代码复制过去。功能开始时看不出差异,后来一个入口补了范围校验,另一个入口仍拿旧选区修改内容,问题才浮现。本篇从这个重复入口出发,把编辑命令收拢到同一函数,再让菜单和页面按钮只表达用户意图。

选择菜单与编辑命令归口技术封面

先把菜单当成入口,而不是第二个编辑器

工程仍为 NoteTypeset,使用 API 12,并继续保留第一篇的 NoteSeed.ets。本篇结束第四篇的混排实验,重新进入正式文本便笺。页面不会加载 BuilderSpan 实验内容,命令也不会试图解释内嵌对象。这个切换遵循既定的文档范围:可编辑正文由文本跨度组成,混排能力在独立面板中研究。

自定义菜单负责呈现两项明确动作:加粗强调与恢复常规。它不持有另一份正文,不创建第二个控制器,也不缓存一次菜单打开时的完整跨度数组。实际执行仍交给当前页面拥有的控制器。菜单布局可以变化,编辑命令的范围规则则只维护一份,避免入口之间渐渐分叉。

这里使用 bindSelectionMenu 为文本选择绑定构建函数,并分别覆盖长按与右键响应。触屏与键鼠有不同入口,但对应的是同一业务动作;不用为右键菜单另写一个直接修改跨度的函数。其他设备上的选择方式是否触发某个入口,应依照目标版本实际验证,不把一个触屏手势的观察推广到全部输入设备。

页面保留下方两个按钮,有两个目的:让没有打开菜单时也能验证范围拒绝逻辑,并提供相同命令的另一个可见入口。两处按钮不是为了增加操作数量,而是检查架构是否真正归口。无选择时,两处都应给出选择提示;有效选择时,两处都应修改相同的属性集合。

把重复代码收成一个有边界的命令

新增 common/TextCommands.ets。函数接收控制器与目标格式,不接受外部传来的旧起止数字。它在执行时读取选择,拒绝无效范围,再应用明确的字重和颜色。返回值是可以展示给用户的结果说明,不包含组件实例或排版对象,页面不需要再理解一套内部格式结构。

export function formatSelection(
  controller: RichEditorController, strong: boolean
): string {
  const selected = controller.getSelection().selection;
  const start = selected[0];
  const end = selected[1];
  if (!Number.isInteger(start) || !Number.isInteger(end) ||
    start < 0 || end <= start) {
    return '请先选择有效文字';
  }
  controller.updateSpanStyle({
    start: start, end: end,
    textStyle: { fontWeight: strong ? FontWeight.Bold : FontWeight.Normal,
      fontColor: strong ? '#B45309' : '#263246' }
  });
  controller.closeSelectionMenu();
  return strong ? '已应用强调格式' : '已恢复常规字重与颜色';
}

恢复常规不是恢复历史操作,也不是清除整篇所有样式。它仅把当前范围的字重改为普通、颜色改为正文色;字号、段落对齐和其他未声明属性保持原有规则。这个名字若在真实产品中仍然容易造成误解,可以直接显示为“普通字重与正文色”。不能用一个简短按钮名掩盖实际会清除哪些属性。

函数没有根据第一个跨度推断当前是否已经加粗。混合选择可能同时含有多种字重,简单取反会给出不一致结果。明确的目标动作避免了这层推断:用户选择强调,就把目标属性设为强调;用户选择常规,就把目标属性设为常规。需要三态工具栏时,再为混合状态定义独立的显示与决策。

updateSpanStyle 不负责替我们决定菜单何时关闭。命令成功更新后显式调用 closeSelectionMenu,让用户回到编辑内容;无效范围时保留选择提示,不伪装成成功。关闭菜单也不会把控制器清空或重建文档,这些动作具有不同职责,不应放进一个含糊的收尾函数。

DevEco Studio中查看统一文本格式命令

图 2:TextCommands.ets 承担读取、校验、更新和关闭菜单;页面只是把不同入口映射到相同函数。

页面只把意图传到同一个出口

将 pages/Index.ets 更新为下面的实现。页面负责组件就绪与销毁边界,公共命令负责当次选择的有效性,两层各自检查自己的条件。自定义菜单只有两项操作,不需要把完整编辑工具栏塞进选区附近,也不会通过大量按钮遮挡被选择的文本。

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

@Entry
@Component
struct Index {
  private controller: RichEditorController = new RichEditorController();
  private ready: boolean = false;
  @State private status: string = '等待编辑器';

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

  private execute(strong: boolean): void {
    if (!this.ready) { return; }
    this.status = formatSelection(this.controller, strong);
  }

  @Builder
  formatMenu() {
    Column({ space: 8 }) {
      Button('加粗强调').width(160).onClick(() => { this.execute(true); })
      Button('恢复常规').width(160).onClick(() => { this.execute(false); })
    }.padding(12).backgroundColor(Color.White).borderRadius(12)
  }

  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;
          fillSeed(this.controller);
          this.status = '选择文字后使用菜单或下方按钮';
        })
        .onWillChange((value: RichEditorChangeValue) => {
          return value.replacedImageSpans.length === 0 &&
            value.replacedSymbolSpans.length === 0;
        })
        .allowDrop(null)
        .bindSelectionMenu(RichEditorSpanType.TEXT,
          this.formatMenu, RichEditorResponseType.LONG_PRESS)
        .bindSelectionMenu(RichEditorSpanType.TEXT,
          this.formatMenu, RichEditorResponseType.RIGHT_CLICK)
      Row({ space: 12 }) {
        Button('强调选区').onClick(() => { this.execute(true); })
        Button('恢复常规').onClick(() => { this.execute(false); })
      }
      Text(this.status).fontSize(14).fontColor('#4F5E8C')
      Text('长按文字打开格式菜单').fontSize(14).fontColor('#68758A')
      Text('恢复常规只改变字重与颜色').fontSize(13).fontColor('#68758A')
    }.width('100%').height('100%').padding({ left: 24, right: 24, top: 40 })
      .backgroundColor('#F6F4EF')
  }
}

文本输入入口同时拒绝图片和符号替换,并以 API 12 的 allowDrop(null) 拒绝拖入,确保当前格式命令工作在明确的正文范围内。空数组属于默认配置,不能把它当成拒绝所有类型。这不是对 RichEditor 全部能力的限制,而是当前便笺选择的内容契约。若将来支持图片或对象,应先扩展命令对混合选择的解释,再开放对应入口,避免先放进文档、之后靠强制类型转换补救。

onWillChange 返回值表达是否接受这次内容变化,不负责执行另一份替换操作。拒绝时不应该又在回调中插入一个“替代对象”,否则一次用户输入可能变成嵌套写入。这里采用简单准入:纯文本变化继续,包含不支持对象的变化拒绝。提示与降级体验可以独立增加,不能改变准入规则的确定性。

当前菜单没有冒用剪切、复制或粘贴的名称,因为我们没有实现这些操作。绑定自定义菜单以后,应检查系统默认菜单在当前响应路径上的替换关系,并准确呈现已有功能。若产品需要保留完整系统编辑能力,可以采用官方提供的菜单配置组件或补全相应动作,但不能只画出几个熟悉图标就宣称功能齐全。

便笺格式菜单页面的竖屏效果

图 3:初始界面尚未展开选择菜单;下方两个按钮与长按菜单采用同一命令,恢复常规的属性范围写在页面提示中。

用入口对照找出隐藏分叉

首先不选文字,分别尝试页面按钮与可以触发的菜单动作。公共函数应拒绝空范围,不能因为某个入口更接近编辑器,就跳过校验。再选中一段正文,通过页面按钮加粗,恢复后以同样选择通过菜单加粗,比较文字内容与属性集合。结果一致,才说明两个入口没有各自保留一套规则。

选择跨越不同字号的文本,再执行恢复常规。预期变化是字重与颜色,字号不被统一。这组用例检查命令有没有无意扩大清理范围。若界面把标题字号也重置,应回到公共函数检查提交字段,而不是分别修补两个按钮。归口后的好处就在于一个缺陷只有一个修改位置。

接着打开菜单,再改变选择或让选择消失,然后尝试执行。命令读取的是执行时的范围,而不是菜单显示时保存的范围。产品若需要保持菜单打开期间的选择上下文,应设计带文档版本的选择会话,并在内容变化时失效;当前没有这项需求,所以不引入持久缓存,也不承诺旧选择仍然有效。

键鼠场景应单独检查右键响应:焦点是否仍在编辑区,菜单是否位于可操作位置,操作后是否收起。不要把右键与触屏长按当成同一条事件链。它们共用业务命令,但触发时机和系统交互不同;架构复用的是确定的格式操作,而不是假设所有输入方式具有相同的界面行为。

菜单与工具栏共用命令的时序交互图

图 4:无论动作来自菜单还是按钮,都先读取当前选区,再通过同一范围检查与样式更新路径。

还需要检查输入中的组合状态。输入法候选尚未最终提交时,不应由自定义按钮偷偷替换整篇文本;本例命令只处理控制器当前可选择的内容,不建立第二份候选文本。若要增加提交前限制,应依据编辑器提供的内容变化接口设计,并验证中文输入、删除与撤销路径,避免用字符串长度拦截所有中间过程。

菜单文字也应该保持可访问和可理解。两项操作使用明确中文名称,没有只依靠颜色或一个字母图标区分。焦点、键盘与辅助技术的实际体验需要在目标设备检查;代码结构统一并不自动证明所有交互方式都顺畅。可以把入口可达性与命令正确性分开记录,避免一个通过掩盖另一个问题。

菜单显示时机还应与业务命令的可用性分开考虑。一个菜单成功出现,说明响应类型与构建函数已经连通;命令能否执行,则取决于当前控制器和选择范围。排查时先验证菜单触发,再验证点击回调,再验证范围与属性更新,可以避免把所有问题都归到某个绑定方法上。每一层只观察自己的输入输出,问题通常更容易缩小。

关闭菜单属于成功路径的界面反馈,不应被写成无条件的第一步。先关闭再发现范围无效,会让用户失去当前上下文,却没有得到任何格式变化;先完成验证,再修改并关闭,交互含义更清楚。若后续命令增加异步处理,还应说明菜单等待、取消和文档变化的规则,不能直接把本例的同步假设带过去。

对命令结果的记录也可以保持克制。当前状态文字只说明已应用哪组属性,不声称文档已经保存到文件或云端。格式更新、快照生成和持久化成功是三个不同事件,统一写成保存成功会误导用户。下一篇继续保留这种区分,让界面反馈随着实际完成的动作变化。

重构留下的是一条可信的写入路径

新的快捷键或侧边工具面板可以复用 formatSelection,但调用方仍需保证控制器处于当前有效页面。公共函数不是跨页面编辑服务,不应被全局定时任务持有。页面销毁只关闭 ready,既不在销毁回调中更新状态,也不让菜单保存一个能够继续写入旧页面的长期引用。

归口并不意味着把所有功能放进一个巨大方法。字符样式命令、段落样式命令和内容导出有不同的输入输出,应分别保持清楚。当前函数只处理两个明确样式属性,下一篇的快照函数只读取文字;它们可以共享范围概念,但不应该顺手触发彼此。读操作不改变排版,格式操作不承担保存文档。

最后一篇将把已编辑文本形成纯文本快照,并区分快照、当前内容与编辑状态。自定义菜单的收益会在这里继续体现:每条格式路径保持相同属性边界,快照只需要遵守正式正文的文本契约,而不用猜测某个入口是否偷偷插入了其他对象或重建了文档。

官方资料

Logo

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

更多推荐