鸿蒙 PC Markdown 编辑器无障碍国际化工程:焦点、系统字体与双向文本
鸿蒙 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 Shortcuts 和 Export 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-CN 或 en-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,已同步到 GitCodemain。
测试过程保留了一个有价值的失败:最大字体 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、长文档、图片、公式、图表、多标签和多窗口压力矩阵。无障碍不是做完一次就封存的页面检查,而应该成为每个新增功能必须重新通过的桌面质量门禁。
更多推荐



所有评论(0)