鸿蒙 PC Markdown 编辑器无障碍国际化工程:焦点、系统字体与双向文本

Markdown 编辑器的无障碍不能只理解成“给几个按钮加名称”。在鸿蒙 PC 上,一条真实写作路径会跨过 ArkUI 活动栏、文件树、标签栏、原生工具栏、ArkWeb、CodeMirror、搜索面板和模态命令。如果其中任何一层吞掉 Tab、把焦点画成不可见、在系统字体放大后遮住按钮,用户就无法稳定完成任务。国际化也不只是把中文字符串换成英文:日期、数字、文件大小、超长文件名、阿拉伯文、希伯来文、组合字符和 Emoji 都会改变桌面界面的实际布局与文本方向。

本文以 OhMarkdown 的无障碍与国际化实现为例,说明如何在鸿蒙 PC 混合编辑器里建立一套可验证的工程规则。仓库地址是 https://gitcode.com/VON-/codex_md_oh,对应功能提交是 75f26f6。代码由 ArkUI 原生工作台、ArkWeb 和 CodeMirror 组成,验证环境是 DevEco Studio MateBook Pro 2in1 模拟器,HarmonyOS 6.1.1、API 24。本文会明确区分模拟器结论和仍需真机补齐的读屏、物理键盘与 2.0 字体测试。

在这里插入图片描述

先把无障碍拆成可失败的系统

如果需求只写“支持无障碍”,实现很容易退化成零散修补。更有效的拆法是按失败方式建模:焦点是否可达,焦点是否可见,焦点是否能离开,控件是否有名称和状态,文字放大后是否仍可操作,双向文本是否改变源码,区域格式是否被硬编码。每一类都有不同的责任层。

ArkUI 负责系统窗口、活动栏、文件树、标签、设置、系统字体和原生可访问语义;ArkWeb 负责网页焦点边界;CodeMirror 负责编辑器键盘行为和正文事实来源;CSS 负责可见焦点和段落方向;本地化服务负责日期、数字和文件大小。把所有问题都塞进 WorkspaceShell,或者全部交给 Web,都无法得到稳定边界。

本次实现使用以下退出规则:

  • ArkWeb 获得焦点后必须能用 Tab 回到 ArkUI,不能形成键盘陷阱。
  • 模态面板打开时,Tab 不能穿到遮罩后的编辑区;Escape 必须归还编辑器。
  • 文件、搜索结果、大纲和标签除了鼠标点击,还必须接受 Enter 或 Space。
  • 纯图标按钮必须有可访问名称,陌生图标必须有悬浮提示。
  • 选择状态不能只靠绿色背景,必须暴露 selected 或 checked。
  • 系统最大字体下,核心命令不能重叠、不可达或只剩无法辨认的省略号。
  • RTL、CJK、Emoji 和组合字符在编辑与预览之间往返时,Markdown 正文必须完全不变。

这些规则可以进入自动化、控件树和模拟器任务,而不是停留在主观“看起来还行”。

字体缩放从应用配置开始

ArkUI 控件想跟随系统字体,应用必须先在 AppScope 声明配置。OhMarkdown 没有在页面里写一组私有字号倍率,而是让系统成为用户偏好的来源,同时把当前产品经过设计的最大支持比例限制为 2.0:

{
  "configuration": {
    "fontSizeScale": "followSystem",
    "fontSizeMaxScale": "2"
  }
}

然后在 AppScope/app.json5 引用资源:

{
  "app": {
    "bundleName": "com.example.ohmarkdown",
    "vendor": "example",
    "versionCode": 1000000,
    "versionName": "1.0.0",
    "buildVersion": "1",
    "icon": "$media:layered_image",
    "label": "$string:app_name",
    "configuration": "$profile:configuration"
  }
}

这里的上限不是用来拒绝无障碍,而是诚实表达当前验证边界。设置成 3.2 却没有任何页面在 3.2 下通过,只会把未验证布局交给用户。项目当前目标是先把 2.0 作为明确支持上限,并继续在实际提供该档位的鸿蒙 PC 设备上补完整矩阵。

不把配置值误当运行时实际比例

应用配置声明的是策略,真正布局需要知道当前设备实际采用了多少字体比例。模拟器系统设置的滑块移动到最大后,实际回调并不是 1.5 或 2.0,而是约 1.45。若断点写死在 1.5,界面已经明显放大,响应式布局却永远不触发。

OhMarkdown 没有依赖不可观察的假设,而是利用同一个 UIContext 下 1 vp 和 1 fp 的像素值计算比例:

export const MIN_SUPPORTED_FONT_SCALE: number = 1;
export const MAX_SUPPORTED_FONT_SCALE: number = 2;
export const EXPANDED_TOOLBAR_FONT_SCALE: number = 1.4;

export function normalizeFontScale(value: number): number {
  if (!Number.isFinite(value)) {
    return MIN_SUPPORTED_FONT_SCALE;
  }
  return Math.max(MIN_SUPPORTED_FONT_SCALE,
    Math.min(MAX_SUPPORTED_FONT_SCALE, value));
}

export function resolveFontScaleFromPixels(
  vpPixels: number,
  fpPixels: number
): number {
  if (!Number.isFinite(vpPixels) || !Number.isFinite(fpPixels) || vpPixels <= 0) {
    return MIN_SUPPORTED_FONT_SCALE;
  }
  return normalizeFontScale(fpPixels / vpPixels);
}

export function shouldExpandToolbar(value: number): boolean {
  return normalizeFontScale(value) >= EXPANDED_TOOLBAR_FONT_SCALE;
}

组件只负责读取上下文并决定是否使用扩展布局:

private usesExpandedToolbar(): boolean {
  try {
    const uiContext = this.getUIContext();
    return shouldExpandToolbar(
      resolveFontScaleFromPixels(uiContext.vp2px(1), uiContext.fp2px(1))
    );
  } catch (_) {
    return false;
  }
}

这段代码的价值不在数学复杂度,而在于把设备差异变成可测试输入。单元测试覆盖非有限值、低于 1、高于 2、像素分母为零和 1.4 断点;模拟器再验证真实 1.45 会走到扩展布局。纯函数和设备事实互相补足。

工具栏的目标是稳定可达,不是强行塞满

PC 编辑器顶部往往同时包含多标签、新建标签、打开、保存、源码、即时、分栏、预览、新建窗口和侧栏切换。普通字体下可以横向排列;字体放大后继续等比例压缩,会出现三类问题:文字省略、点击区重叠、编辑区域被挤到异常宽度。

OhMarkdown 的处理不是把字体缩回去,而是提高工具栏高度、给内容一个稳定最小宽度,并允许整个工具栏横向滚动:

Scroll() {
  Row() {
    // 标签、打开、保存、四种模式与图标命令
  }
  .height('100%')
  .width('100%')
  .constraintSize({ minWidth: this.usesExpandedToolbar() ? 1100 : 0 })
}
.width('100%')
.height(this.usesExpandedToolbar() ? 52 : 42)
.scrollable(ScrollDirection.Horizontal)
.scrollBar(this.usesExpandedToolbar() ? BarState.Auto : BarState.Off)

横向滚动是一项明确取舍:它不会让所有命令始终同屏,但能保证每个命令保持可读、可聚焦和稳定尺寸。对于重复写作工具,重叠和随机缩放比“需要滚动到右侧”更糟。真机阶段还要验证触控板横向手势、Shift+滚轮,以及键盘焦点移动到屏外按钮时系统能否自动把它滚入视口。

图标和文字也不能采用同一缩放策略。SymbolGlyph 是工具形状,不是正文,随系统字体无限放大会改变工具栏几何。实现对图标设置 maxFontScale(1),对按钮文字保留 maxFontScale(2)。这样用户需要阅读的命令会放大,熟悉的工具图标维持稳定点击区。

侧栏中的分段控件需要改变方向

搜索面板原本有“当前文档、工作区、快速打开”三个横向分段。最大字体时,如果只增加高度,中文仍可能勉强可见,English 却容易省略。设置里的语言、自动保存、图片目录和拖放模式也有同样问题。

扩展布局直接改变方向,而不是尝试精确计算每个翻译字符串的宽度:

if (this.usesExpandedToolbar()) {
  Column({ space: 2 }) {
    this.searchModeButton(
      $r('app.string.search_current_document'),
      SearchPanelMode.DOCUMENT,
      '100%',
      true
    )
    this.searchModeButton(
      $r('app.string.search_workspace'),
      SearchPanelMode.WORKSPACE,
      '100%',
      true
    )
    this.searchModeButton(
      $r('app.string.quick_open'),
      SearchPanelMode.QUICK_OPEN,
      '100%',
      true
    )
  }
  .width('100%')
  .height(128)
} else {
  Row() {
    // 三个 33.33% 按钮
  }
  .height(32)
}

替换与全部替换同样在扩展字体下改成两个全宽按钮。设置面板自身是 Scroll,因此纵向增长不会让后面的资源设置永久不可达。这里使用单一断点,不为每个分组发明独立阈值,避免桌面界面在相邻字号间反复跳动。

一次真实截图发现了自动化没有覆盖的省略

最大字体的中文设置面板通过后,如果直接宣布完成,会漏掉 English 的长命令。第一次切换 English 后,Keyboard ShortcutsExport Preview PNG 仍显示省略号。按钮宽度并不小,真正消耗空间的是 ArkUI Button 默认水平内边距。

最终修复没有粗暴扩大整个侧栏,而是为这组全宽命令显式设置 4 vp 水平内边距:

Button($r('app.string.keyboard_shortcuts_action'))
  .type(ButtonType.Normal)
  .width('100%')
  .height(34)
  .padding({ left: 4, right: 4 })
  .borderRadius(5)
  .fontSize(13)
  .maxFontScale(2)

相同规则应用于命令面板、HTML、PDF、PNG 和 Markdown 分享按钮。重新构建、安装后,两个长英文命令完整显示,中文布局没有退化。

在这里插入图片描述

这个过程说明,字符串资源齐全不等于国际化完成。每种语言都要进入真实布局;而且测试不能只检查元素存在,必须检查用户看见的最终结果。截图不是装饰,它在这里发现了明确产品缺陷。

ArkUI 行项目要拥有完整键盘语义

文件树、搜索结果、大纲和标签过去主要依赖 .onClick()。鼠标路径正常,不代表键盘路径存在。实现为这些行项目补充焦点与按钮角色,并让 Enter/Space 调用同一个动作:

private handleFocusableActionKey(
  event: KeyEvent,
  action: () => void
): boolean {
  if (event.type !== KeyType.Down ||
    (event.keyCode !== KeyCode.KEYCODE_ENTER &&
      event.keyCode !== KeyCode.KEYCODE_SPACE)) {
    return false;
  }
  action();
  return true;
}

文件行的接入方式如下:

.focusable(true)
.tabStop(true)
.accessibilityRole(AccessibilityRoleType.BUTTON)
.accessibilityText(entry.name)
.accessibilitySelected(this.documentUri === entry.uri)
.onKeyEvent((event: KeyEvent): boolean =>
  this.handleFocusableActionKey(event, () => {
    if (entry.isDirectory) {
      this.toggleWorkspaceDirectory(entry);
    } else {
      this.requestOpenWorkspaceDocument(entry);
    }
  }))

标签还会把脏状态作为描述暴露:当前标签不只显示绿色下划线,读屏语义还能得到“已修改”或“已保存”。视觉省略的超长文件名则继续把完整 session.name 作为可访问文本,稳定标签宽度和完整名称并不冲突。

选择状态也需要显式表达。活动栏使用 accessibilitySelected,同步滚动和复选框使用 accessibilityChecked,语言、搜索模式、自动保存与资源规则使用 selected。这样系统辅助技术不必从背景色猜状态。

图标按钮必须有名字,也要照顾普通鼠标用户

活动栏的文件、搜索、大纲、历史和更多操作都是图标。仅把图标画清楚仍不够:系统辅助能力需要名称,第一次使用的鼠标用户也需要提示。统一 Builder 同时设置可访问文本和悬浮提示:

private iconButton(icon: Resource, label: Resource, action: () => void) {
  Button() {
    SymbolGlyph(icon)
      .fontSize(18)
      .maxFontScale(1)
      .fontColor([$r('app.color.workspace_icon')])
  }
  .type(ButtonType.Normal)
  .width(32)
  .height(32)
  .accessibilityText(label)
  .bindTips(label, { appearingTime: 500, disappearingTime: 100 })
  .onClick(action)
}

名称来自 ArkUI 字符串资源,所以应用切换简体中文或 English 后,辅助名称和提示使用同一语言来源。它避免界面写着“保存”,提示却仍是英文,也避免代码里散落硬编码字符串。

ArkWeb 的难点不是进入焦点,而是离开焦点

混合编辑器很容易出现键盘陷阱:Tab 在网页内部循环,用户无法回到原生工具栏;或者焦点虽然离开,页面没有任何可见提示。OhMarkdown 分两层解决。

第一层是 Web 内部模态面板。命令面板、快捷键设置、链接助手和冲突比较打开后,焦点只能在可见、启用、tabIndex >= 0 的元素间循环:

const MODAL_FOCUSABLE_SELECTOR =
  'button:not(:disabled), input:not(:disabled), select:not(:disabled), ' +
  'textarea:not(:disabled), [tabindex]';

function getModalFocusableElements(dialog: HTMLElement): Array<HTMLElement> {
  return Array.from(
    dialog.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR)
  ).filter((element) =>
    element.tabIndex >= 0 &&
    !element.hasAttribute('hidden') &&
    element.getClientRects().length > 0
  );
}

function trapModalFocus(dialog: HTMLElement, event: KeyboardEvent): void {
  if (event.key !== 'Tab' || dialog.hidden) return;
  const focusable = getModalFocusableElements(dialog);
  if (focusable.length === 0) {
    event.preventDefault();
    dialog.focus();
    return;
  }
  const first = focusable[0];
  const last = focusable[focusable.length - 1];
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
}

命令和链接候选采用方向键管理活动项,因此它们被设置为 tabIndex=-1,避免一百条候选把 Tab 顺序扩张成一条长隧道。Escape 关闭面板后继续调用编辑器 focus(),用户能回到原任务。

第二层是 ArkWeb 和 ArkUI 的边界。模拟器把源码编辑器聚焦后,注入 HarmonyOS KEYCODE_TAB=2049,前景窗口的文档活动按钮出现系统蓝色焦点环。这证明 Web 没有拦截末端 Tab,系统能够继续进入 ArkUI。

在这里插入图片描述

HDC 注入不是物理键盘结论,但它比只阅读代码更接近真实系统路由。真机还要执行打开、编辑、查找、保存、切标签和关闭的完整纯键盘任务,并检查输入法候选窗、组合键和触控板是否改变焦点。

焦点可见要覆盖深色和浅色主题

浏览器默认 outline 容易被 reset 样式取消,CodeMirror 过去也显式使用 outline: none。本次改为统一的主题变量:

:root {
  --focus-ring: #087a63;
}

.cm-editor.cm-focused {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
}

:where(button, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--focus-ring) !important;
  outline-offset: 2px !important;
}

body[data-theme="dark"] {
  --focus-ring: #63cdb5;
}

浅色用较深的绿色,深色用更亮的青绿色,目的不是品牌装饰,而是保证焦点和相邻背景有明确对比。CodeMirror 使用负 offset 把轮廓收在编辑器边界内,避免改变布局尺寸;普通控件使用正 offset,让焦点不被控件边框吞没。

双向文本处理必须服从源码保真

阿拉伯文和希伯来文并不会自动要求整个应用镜像。Markdown 文档可能在一个页面里同时出现英文标记、中文说明、阿拉伯文标题、希伯来文段落、Emoji 和代码。编辑器首要规则是保存用户输入的准确序列,不根据视觉方向改写正文。

CSS 使用 unicode-bidi: plaintext 让每个文本块按自身首个强方向字符决定显示方向:

.cm-line,
#preview :is(p, li, blockquote, h1, h2, h3, h4, h5, h6, td, th),
.conflict-line__content {
  unicode-bidi: plaintext;
}

#preview :is(p, li, blockquote, h1, h2, h3, h4, h5, h6, td, th),
.command-palette__title,
.link-assistant__label,
.link-assistant__target {
  overflow-wrap: anywhere;
}

自动化语料为:

# العربية مع Markdown

שלום עולם,鸿蒙 PC,é 与 Emoji 👩‍💻

测试不仅检查预览包含这些字,还调用 getDocument() 与原字符串做完全相等比较。组合字符 e\u0301 不能被测试框架偷偷改成预组字符;Emoji 只需确认正文和预览保留,不对视觉宽度做脆弱像素假设。CodeMirror 编辑行与预览段落的计算样式都必须返回 unicode-bidi: plaintext

区域格式不要继续手写字符串

版本历史原来用 getFullYear()padStart() 和固定 YYYY-MM-DD HH:mm:ss 拼接时间,文件大小用 toFixed(1) 固定小数点。这在中文开发机上看起来正常,但不会自动适配区域设置的数字分隔符、日期顺序和时制。

本地化服务改用标准 Intl

function resolveFormattingLocale(locale?: string): string {
  return locale ?? i18n.System.getSystemLocaleInstance().toString();
}

export function formatLocalizedDateTime(
  value: number,
  locale?: string
): string {
  return new Intl.DateTimeFormat(resolveFormattingLocale(locale), {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit'
  }).format(new Date(value));
}

export function formatLocalizedNumber(
  value: number,
  maximumFractionDigits: number,
  locale?: string
): string {
  return new Intl.NumberFormat(resolveFormattingLocale(locale), {
    minimumFractionDigits: 0,
    maximumFractionDigits
  }).format(value);
}

文件大小仍使用 B、KiB、MiB 这一组稳定二进制单位,但数值交给区域格式。界面语言和地区不是同一个概念:用户可以选择 English,同时保留中文地区的日期和数字习惯。因此默认读取系统区域,而测试允许显式传入 zh-CNen-US 得到确定结果。

自动化要覆盖行为,不要只数属性

Web 新增两项核心测试。模态测试打开命令面板,确认查询框获得焦点、候选项不进入 Tab 顺序、首尾 Tab 循环成立、焦点轮廓为 2 px solid;Escape 后 CodeMirror 恢复焦点。然后对快捷键设置重复首尾循环。

RTL 测试设置完整正文、切换预览、比较 getDocument(),分别检查阿拉伯文、希伯来文、中文、组合字符和 Emoji,再读取编辑行与预览段落的 unicodeBidi 计算样式。

ArkTS 纯函数测试覆盖:

expect(normalizeFontScale(Number.NaN)).assertEqual(1);
expect(normalizeFontScale(0.8)).assertEqual(1);
expect(normalizeFontScale(1.49)).assertEqual(1.49);
expect(normalizeFontScale(2.4)).assertEqual(2);
expect(resolveFontScaleFromPixels(2, 3.5)).assertEqual(1.75);
expect(shouldExpandToolbar(1.39)).assertFalse();
expect(shouldExpandToolbar(1.4)).assertTrue();
expect(formatLocalizedNumber(1234.5, 1, 'en-US'))
  .assertEqual('1,234.5');

最终结果如下:

  • Playwright:59/59
  • Debug HAP:构建成功。
  • ArkTS UnitTestBuild:构建成功。
  • ohosTest HAP:构建成功。
  • MateBook Pro 2in1 模拟器 ohosTest:15/15,Failure 0、Error 0,总耗时 1963 ms。
  • 生产单 HTML:7,690,528 字节,保持单文件、零外部子资源。
  • 功能提交:75f26f6,已同步到 GitCode main

测试过程保留了一个有价值的失败:最大字体 English 首次截图出现长按钮省略。它没有被当成“系统默认行为”忽略,而是进入修复、重新构建、重新安装和重新截图闭环。质量记录应该保留这种过程,因为它说明设备验收确实在发现问题。

模拟器可以证明什么,不能证明什么

模拟器能够证明当前 HAP 在 HarmonyOS 6.1.1 / API 24 上运行,系统字体设置能传给应用,实际 1.45 触发扩展布局,中文与 English 可以切换,RTL 预览不重叠,Tab 能从 ArkWeb 进入 ArkUI,最终原生测试通过。它还可以提供 3120 x 2080 的真实应用截图,而不是设计稿。

模拟器不能证明真实屏幕阅读器的朗读内容和顺序。控件树里有 name、role、selected 和 checked,只能说明应用提供了语义,不说明某个真机系统版本一定以预期方式朗读。HDC 的键码注入也不能替代物理键盘的按键扫描、组合键竞争、输入法候选窗和触控板焦点行为。

当前模拟器字体滑块最大实际为 1.45,因此不能声称已经完成 2.0 视觉矩阵。应用支持上限是 2.0,下一步需要在提供 200% 字体的目标设备上逐页验证。真实读屏、物理键盘、2.0 字体和触控板都是 RC 闸门,不会因为工程小阶段完成而自动消失。

对鸿蒙 PC 产品竞争力的意义

无障碍与国际化不是独立的合规附加项,它们直接改善所有桌面用户的稳定性。可见焦点让快捷操作更可预测;明确 selected/checked 让状态不再只依赖颜色;大字体响应式布局减少窗口缩小时的重叠;区域格式避免专业历史记录看起来像硬编码原型;RTL 和组合字符保真则保护 Markdown 作为跨语言纯文本格式的根本价值。

与“功能很多但边界模糊”的编辑器相比,OhMarkdown 的优势目标是可解释:正文始终是唯一事实来源,显示方向不反写源码,字体缩放不偷偷缩小用户文字,Web 模态不困住焦点,原生和网页之间的键盘路由有设备证据。当前这些能力达到模拟器证据完整的工程水平,但没有竞品统一任务和真机数据前,不对外宣称全面领先。

真正达到可发布的 4 分,需要同一台鸿蒙 PC 真机、同一字体比例、同一组长文件名与 RTL 文档,让 OhMarkdown 和对标产品分别完成打开、查找、编辑、保存、切标签和导出任务;记录完成率、按键数、焦点丢失、截断和总耗时。只有数据能把“我们重视无障碍”变成产品优势。

结语

鸿蒙 PC Markdown 编辑器的无障碍工程,本质上是一套跨 ArkUI、ArkWeb、CodeMirror、CSS、系统配置和本地化服务的状态协议。系统字体决定真实排版压力,焦点协议决定键盘能否完成任务,语义决定辅助技术能否理解控件,Unicode 规则决定跨语言正文能否保持原样。

OhMarkdown 本次实现建立了可继续扩展的基线:应用跟随系统字体并限制已支持范围;实际比例触发响应式方向变化;顶部命令在放大后保持可滚动和可达;核心 ArkUI 行项目具备键盘动作与语义;Web 模态焦点可循环、可退出;RTL、CJK、Emoji 与组合字符不改变正文;日期和数字遵循区域设置。更重要的是,报告没有把模拟器结果包装成真机读屏结论。

接下来的性能与兼容性阶段会把这些规则带入 CommonMark/GFM、长文档、图片、公式、图表、多标签和多窗口压力矩阵。无障碍不是做完一次就封存的页面检查,而应该成为每个新增功能必须重新通过的桌面质量门禁。

Logo

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

更多推荐