鸿蒙 PC Markdown 编辑器 ArkWeb 运行时本地化:不重载 CodeMirror 的中英文切换

鸿蒙 PC Markdown 编辑器通常不会只由原生控件构成。OhMarkdown 的文件、设置和系统能力在 ArkUI,源码编辑、预览、命令面板与三方差异运行在离线 ArkWeb。语言切换如果通过重新加载 Web 页面实现,会同时重建 CodeMirror 文档、选择区、撤销历史、滚动位置和临时预览,代价远高于几段文字更新;如果只改变静态 DOM,编辑器占位、正在显示的差异和命令搜索又可能停留在旧语言。

本文基于公开仓库 https://gitcode.com/VON-/codex_md_oh 的真实代码,核心提交为 ed13ee0,当前设备复核基线为 0d8d38b。实现通过强类型消息表、CodeMirror Compartment、固定 Bridge 入口和可重复重绘,在不重载页面的情况下切换中文与英文。Playwright 最终 30/30,MateBook Pro 2in1 模拟器 ohosTest 7/7。文章不把未实现的任意语言包、在线翻译或插件词典描述为现有能力。

Web 编辑器中真正需要本地化的对象

第一类是可访问名称:整个 Markdown 工作区、源码编辑器、预览区、命令面板、命令输入框和候选列表。第二类是稳定可见文案:CodeMirror 空文档占位、页面标题、无结果提示。第三类是数据驱动 UI:命令标题、分类、关键词,三方差异的栏名、图例和计数。第四类是动态反馈:图片保存、插入、读取失败与超时。

这些对象更新方式不同。普通 DOM 属性可以直接赋值;CodeMirror 占位是扩展,需要通过状态事务重配;命令条目保存在数组中,修改后要重新筛选和渲染;三方差异的统计由 baseline/local/disk 重新计算,不能只换标题;图片状态带文件名或路径,需要消息函数而不是静态字符串。

语言切换不应改变命令 ID、视图模式、文档 sessionId、Bridge 协议或 Markdown 文本。file.save 仍然是 file.save,只是标题从 Save Document 变成“保存文档”。把可见表达与稳定标识分开,是运行时本地化不破坏功能的前提。

用接口约束消息表完整性

web-editor/src/main.ts 定义 EditorMessages,把所有必须提供的静态文本、动态格式函数和命令翻译纳入类型。中英文对象都必须满足同一接口,TypeScript 在构建阶段发现缺失字段,而不是等用户打开某个低频面板。

interface EditorMessages {
  workspaceLabel: string;
  sourceEditorLabel: string;
  previewLabel: string;
  editorPlaceholder: string;
  commandPaletteLabel: string;
  searchCommands: string;
  availableCommands: string;
  noMatchingCommands: string;
  comparisonTitle: string;
  closeComparison: string;
  localImageUnavailable: string;
  imageTimedOut: (path: string) => string;
  savingImage: (name: string) => string;
  conflictCount: (count: number) => string;
  commands: Record<string, CommandTranslation>;
}

动态文本使用函数,避免调用处手工拼接语序。英文 ${count} conflicts 与中文 ${count} 个冲突 由各自词典负责。文件名和路径仍作为参数原样插入,不进行危险转义或改写;显示时使用 textContent 或安全属性,不进入 innerHTML

commandsRecord<string, CommandTranslation>,允许当前命令表按稳定 ID 查找。它没有强制所有任意字符串键都存在,因此 setLocale 仍做存在判断,缺失时保留原始英文作为降级。未来可以把命令 ID 提升为联合类型,进一步让编译器验证全覆盖。

两套词典与明确回退

当前 EditorLocale 只有 enzh-CN。英文是默认词典,简体中文覆盖全部核心 Web 界面。中文命令关键词有意保留部分英文同义词,用户在中文界面输入 undofile 仍能找到命令;这不是漏翻译,而是专业编辑器的双语检索策略。

const EDITOR_MESSAGES: Record<EditorLocale, EditorMessages> = {
  en: {
    editorPlaceholder: 'Start writing Markdown...',
    commandPaletteLabel: 'Command Palette',
    noMatchingCommands: 'No matching commands',
    commands: {
      'file.save': {
        title: 'Save Document',
        category: 'File',
        keywords: 'write persist'
      }
    }
  },
  'zh-CN': {
    editorPlaceholder: '开始编写 Markdown...',
    commandPaletteLabel: '命令面板',
    noMatchingCommands: '没有匹配的命令',
    commands: {
      'file.save': {
        title: '保存文档',
        category: '文件',
        keywords: 'write persist 保存'
      }
    }
  }
};

入口收到任意标签时,只判断是否以 zh 开头,其他统一回退 en。平台原生层已经先把用户设置限制为简体中文或英文,但 Web 再做一次收敛,防止旧版本或测试直接调用传入不可用键。回退不是假装支持法语,而是保证编辑器仍可用。

词典和编辑器脚本一起打入离线单 HTML。语言切换没有网络请求,不会在用户文档打开时下载 JSON,也不会因为离线而出现空白。两套文本增加的包体可控,当前不值得引入动态分包和额外失败点。

CodeMirror Compartment 是关键机制

CodeMirror 6 的占位文案属于扩展。创建 EditorView 时,OhMarkdown 不直接把 placeholder(...) 固定塞进扩展数组,而是用 localeCompartment.of(...) 包装。这样后续可以发一个 reconfigure effect,只替换该扩展,保留 EditorState 的文档、selection、history 和其他 compartment。

let editorLocale: EditorLocale = 'en';
const localeCompartment = new Compartment();

const editor = new EditorView({
  parent: editorHost,
  state: EditorState.create({
    extensions: [
      localeCompartment.of(
        placeholder(getEditorMessages().editorPlaceholder)
      )
    ]
  })
});

运行时更新只分发 effect:

editor.dispatch({
  effects: localeCompartment.reconfigure(
    placeholder(messages.editorPlaceholder)
  )
});

这和新建 EditorView 有本质区别。重建需要重新注入全文、恢复 selection、重新绑定监听、恢复多个 session 和历史,任何遗漏都可能造成数据损失。Compartment 让变更成为 CodeMirror 正常事务,状态对象负责保持其余事实。

同样的模式已经适合主题、只读模式等动态配置。语言独立使用一个 compartment,避免切换占位时覆盖其他扩展。大型文档中也只重配固定扩展,不重新解析 Markdown 全文。

setLocale 的同步更新顺序

setLocale 首先收敛词典键,然后更新 HTML 语言、标题和各区域 ARIA,再改命令数组,最后重配 CodeMirror、重绘命令面板和可见差异。顺序确保后续渲染读取的是新词典。

function setLocale(locale: string): void {
  editorLocale = locale.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
  const messages = getEditorMessages();
  document.documentElement.lang = editorLocale;
  document.title = editorLocale === 'zh-CN' ? 'OhMarkdown 编辑器' : 'OhMarkdown Editor';
  workspace.setAttribute('aria-label', messages.workspaceLabel);
  editorHost.setAttribute('aria-label', messages.sourceEditorLabel);
  preview.setAttribute('aria-label', messages.previewLabel);
  commandPalette.setAttribute('aria-label', messages.commandPaletteLabel);
  commandQuery.placeholder = messages.searchCommands;

  editorCommands.forEach((command) => {
    const translated = messages.commands[command.id];
    if (translated) {
      command.title = translated.title;
      command.category = translated.category;
      command.keywords = translated.keywords;
    }
  });
  editor.dispatch({
    effects: localeCompartment.reconfigure(placeholder(messages.editorPlaceholder))
  });
  renderCommandPalette();
}

document.documentElement.lang 对辅助技术和文本处理都有意义,不能只换页面标题。编辑器、预览与命令面板分别设置 ARIA,避免屏幕阅读器仍朗读旧语言上下文。textContent 与属性赋值不会把翻译解释为 HTML,维持 CSP 和 DOM 安全边界。

函数是幂等的。连续调用两次中文不会叠加监听或创建第二个编辑器,只再次写入相同属性和扩展。Ability 创建、工作台点击、配置更新和 Web Ready 都可以安全地汇入此入口。

命令面板需要翻译标题也翻译检索语料

只翻译命令标题还不够。命令面板评分会将标题、分类和关键词归一化后匹配查询;如果中文界面仍只有英文关键词,用户输入“保存”可能找不到 file.save。中文词典因此给每条命令提供中文标题、分类和混合关键词。

命令 ID 从不翻译。执行时仍按 file.saveworkspace.findview.split 路由,ArkUI Bridge 不需要知道当前语言。这样自动化、快捷键和安全白名单都基于稳定协议。可见命令排序会随词典改变,因为查询语料改变,但 enabled 条件和 execute 函数保持原对象。

setLocale 更新命令数组后立即 renderCommandPalette()。若面板当前打开,用户不会看到旧列表直到下次输入;若查询为中文,新关键词立刻参与匹配。空结果提示、输入 placeholder、列表 aria-label 也在同一次调用更新,避免半中文状态。

Playwright 用中文查询验证实际候选,而不只是读取词典对象。这能发现“翻译已写但渲染未重跑”或“标题变了但搜索索引仍缓存旧值”的问题。

三方差异必须基于现有数据重绘

外部修改比较由 baseline、本地缓冲区和磁盘版本组成。差异面板有标题、关闭按钮、图例、三栏可访问名称、栏头、格式差异说明以及冲突/本地/磁盘计数。语言切换时这些都要同步。

setLocale 先更新固定 DOM,然后判断差异是否可见且 activeThreeWayComparison 存在。满足条件时再次调用 showThreeWayDiff,输入仍是内存中的三份文档和 metadata,不重新读取磁盘,也不改变冲突状态。

if (!conflictComparison.hidden && activeThreeWayComparison) {
  showThreeWayDiff(
    activeThreeWayComparison.baseline,
    activeThreeWayComparison.local,
    activeThreeWayComparison.disk,
    activeThreeWayComparison.metadata
  );
}

重新计算展示比逐个查找所有动态计数文本更可靠。差异算法结果不依赖语言,变化的是说明字符串。因为只在面板可见时执行,普通语言切换不会承担三方比较成本。大比较已有降级文本,也从当前词典读取。

重绘不能改变用户选择,因为差异面板是只读比较视图,真正的 Keep Local、Use Disk、Save As 操作仍在原生层。Web 只展示三份内容,不持有文件写权限。

图片反馈的动态消息函数

本地图片粘贴、拖放和重新打开预览会产生文件名、路径和计数。消息表使用函数格式化,例如 savingImage(name)imageTimedOut(path),而不是让调用点写 editorLocale === ...

imageTimedOut: (path: string) => `本地图片读取超时:${path}`,
savingImage: (name: string) => `正在保存 ${name}...`,
savedImage: (name: string) => `已保存 ${name}`,
insertedImage: (name: string) => `已插入 ${name}`

文件名和相对路径属于用户事实,不翻译、不更改编码。文本通过 DOM 安全 API呈现,不能被当作 HTML。底层 Bridge 仍传固定状态和数据,不传本地化后的命令协议。语言改变只影响之后显示的反馈,正在进行的资源事务不会取消或重启。

预览中的本地图片不可用时,现有 image 元素的 title 使用当前词典。读取失败不会因为本地化而泄漏绝对路径:原生资产服务只接受受管理的相对路径,Web 消息仍受既有边界限制。

原生 Bridge 只暴露有限语言入口

ArkUI 调用 Web 前先把平台 locale 映射成 zh-CNen,再用 JSON 序列化构造受限脚本。Web API 对外暴露 setLocale,不暴露消息表写入、动态代码执行或文件能力。

private setEditorLanguage(languageTag: string): void {
  const editorLanguage = resolveEditorLanguage(languageTag);
  this.runEditorScript(
    `window.OhMarkdownEditor?.setLocale(${JSON.stringify(editorLanguage)})`
  );
}

可选链允许 Web 尚未完成加载时安全无操作。之后 onEditorReady 会再次同步当前语言,解决早期调用丢失。原生 @Watch('language') 处理 Ability 配置变化,设置点击则主动同步,两条路径最终都调用同一个方法。

Bridge 不返回用户文档,也不让 Web 查询 Preferences。语言选择的事实保留在原生层,Web 只接收展示所需的有限标签。这符合最小权限:编辑器页面无须知道系统 API 或持久化位置。

为什么不能刷新页面

页面刷新看似最简单:根据语言重新加载 HTML,让初始脚本读新值。但它会销毁多个文档 session 的 CodeMirror 状态、撤销历史、光标、滚动、命令面板选择、预览 Object URL、图片读取队列和当前差异。原生层若想恢复,需要定义并传输一份庞大快照,反而扩大 Bridge 和数据损失面。

刷新还会重新解析离线单 HTML、重新初始化 marked 与 DOMPurify、重新绑定事件。大文档下可能出现明显白屏;未保存输入若尚未同步原生快照,可能直接丢失。语言是轻量表现设置,不应该触发文档生命周期。

当前方案只更新固定 DOM 和一个 CodeMirror compartment,必要时重绘可见的命令或差异。它把成本限制在界面状态,而不是正文长度和会话数量上。对 PC 编辑器这种长时间运行、多标签、频繁切换任务的应用,这是比“刷新后恢复”更可靠的架构。

真实应用截图与运行时证据

下面截图来自 HarmonyOS MateBook Pro 2in1 模拟器的 English 模式。原生工具栏、设置面板和状态栏已经切为英文,Web 源码编辑区的占位文案同步为 Start writing Markdown...,证明两套 UI 栈使用同一次用户选择。

在这里插入图片描述

设备上先切 English,强制停止应用并重启,英文仍保持选中;再切简体中文,Web 占位立即变为“开始编写 Markdown…”,状态栏显示“界面语言已更新”和“0 字”;最后选择跟随系统并重启,中文系统下辅助功能树仍返回跟随系统 selected=true。

截图只能证明可见文案,不能证明 CodeMirror 历史未丢。该部分由 Playwright 在同一个页面与 EditorView 中建立内容、切换语言并继续交互来验证。真实设备和浏览器自动化证据互补,而不是相互替代。

Playwright 覆盖的是状态连续性

web-editor/tests/editor.spec.ts 中的“运行时切换中英文并同步命令面板与三方比较”用例先调用公开 API 切到中文,再检查 HTML lang、编辑器占位、命令面板、中文命令检索和差异区;随后切回英文并确认对应文本恢复。

测试调用路径与原生相同:host.OhMarkdownEditor.setLocale('zh-CN'),而不是直接修改 DOM。这样它会经过消息表、命令翻译、Compartment reconfigure 和差异重绘。测试若只写 document.title = ...,无法证明产品入口正确。

最终 Playwright 30/30,同时包含命令面板、三方冲突、图片资产、工作区命令等既有回归。语言功能没有导致原有协议和编辑操作失败。Web TypeScript 检查、Vite 构建与离线单 HTML 生成通过,说明词典接口和运行时入口能进入最终产物。

设备构建与证据边界

ArkWeb 自动化通过后仍需 HAP 层验证,因为真实入口来自 ArkUI runJavaScript,平台 Webview 生命周期和资源配置不等同于桌面 Chromium。Debug HAP、ArkTS UnitTestBuild、ohosTest HAP 均通过,MateBook Pro 2in1 模拟器 ohosTest 为 7/7

最终布局复核产物 entry-default-unsigned.hap 为 1,520,352 字节,SHA-256 367ab8650479aa1fa8fe73bd1ebadd9a53f46659c850c2e388fc799d5cb88e5bentry-ohosTest-unsigned.hap 为 2,360,824 字节,SHA-256 b7230037b51044fe16168d2c835fb891e1c70f675941a1046165bc895217592c。两份都是未签名测试包。

模拟器证明运行时可见同步和跨重启选择,Playwright 证明 Web 内部状态连续,ohosTest 证明原生核心测试在设备执行。系统设置改变语言后的完整真机生命周期尚未实测,不能从这些结果推导为已经通过。

异常处理与安全边界

未知语言回退英文;缺失命令翻译保留原文本;Web 未 Ready 时可选链安全跳过并在 Ready 后重试;差异不可见时不重绘;动态文件名用 textContent 展示。任何本地化失败都不能改写 Markdown 文档、执行文件命令或关闭会话。

setLocale 接收字符串,但只通过前缀映射到两个内部键,不能让调用方选择对象原型属性或注入词典。原生层又先做领域映射和 JSON 序列化,形成双重限制。CSP、DOMPurify、文件 URI 保留在 ArkTS 的安全边界都没有因语言功能放宽。

词典中的 HTML 特殊字符不会直接进入 innerHTML。命令标题和分类由渲染函数以文本写入,差异标签也是 textContent。国际化内容本身也应被视为数据,而非可信脚本。

性能预算与大文档策略

语言切换的固定工作包括若干属性赋值、十四条命令翻译、一次 CodeMirror effect 和一次命令列表渲染。三方差异只在可见时重绘。没有遍历 Markdown 正文、没有重建文档树、没有文件 I/O 和网络 I/O。

CodeMirror transaction 会触发正常视图更新,但占位在非空文档中不产生正文布局成本。大型文档模式下,编辑器仍保持 source,语言切换不启动预览。命令数量当前很小,重新排序和渲染成本稳定。

更多语言会增加静态 HTML 体积而非运行时多份实例,当前消息表只持有两套小对象。若未来命令、插件和词典增长到明显影响包体,应先测量再决定拆分;动态加载会破坏离线单 HTML 和增加失败路径,不能仅因“国际化架构更高级”而引入。

后续扩展必须守住的契约

新增语言需要同时提供 EditorMessages 全字段、命令翻译、动态计数函数、HTML lang 映射和 Playwright 切换用例。新增命令需要英文与中文标题、分类和关键词;新增 Web 面板需要可见文本与 ARIA 一起接入 setLocale。不能在组件内部新增零散 editorLocale === 分支。

繁体中文需要独立术语和测试,不能直接映射到 zh-CN 后宣称支持。复数规则复杂的语言应使用更结构化的格式化层。插件命令若允许第三方提供,必须定义缺失翻译降级与安全文本渲染,不应让插件覆盖核心命令 ID。

状态连续性仍是最高约束:任何语言扩展都不能重新创建 EditorView,不能清空 Object URL 缓存,不能重置活动 session 或 diff 数据。测试必须在切换前建立真实编辑状态,切换后继续撤销、搜索和保存,才能证明没有隐藏回归。

结论

OhMarkdown 的 ArkWeb 本地化并非把 DOM 文本集中到一个对象就结束。它通过 EditorMessages 约束完整性,用 Compartment 对 CodeMirror 做最小重配,用稳定命令 ID 隔离翻译与协议,用活动比较数据重绘三方差异,再由受限 Bridge 接收原生语言标签。整个过程不刷新页面、不读取网络、不触碰用户文档。

真实英文应用截图、Playwright 30/30、设备 ohosTest 7/7 和 HAP 哈希共同构成可追溯证据。对优先适配鸿蒙 PC 的 Markdown 编辑器,这种运行时连续性尤其重要:语言可以改变,用户正在编辑的内容、光标和历史必须始终留在原位。

Logo

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

更多推荐