鸿蒙 PC Markdown 编辑器分栏阅读:编辑区与预览区双向同步滚动

鸿蒙 PC Markdown 编辑器分栏阅读:编辑区与预览区双向同步滚动
Markdown 分栏模式把源码与渲染结果同时放在桌面窗口中。用户在左侧修改某一段,右侧应尽量保持在同一阅读位置;用户在右侧检查排版时,左侧源码也应跟随。没有同步滚动的分栏只是两块相邻视图,长文档中很快失去上下文。同步算法不稳定则更糟:两边滚动事件互相触发,页面会抖动、回弹,甚至无法停在用户选择的位置。
本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown 的实现,分析如何在 ArkWeb 内部完成双向比例同步,用帧级锁阻止递归反馈,并通过 ArkUI 开关把控制权交给用户。代码来自公开仓库 https://gitcode.com/VON-/codex_md_oh
为什么同步逻辑放在 ArkWeb 内部
OhMarkdown 的工具栏和工作台外壳使用 ArkUI,源码编辑器和预览 DOM 位于同一个 ArkWeb 页面。滚动事件频率可能接近屏幕刷新率,触控板惯性滚动还会在手指离开后持续产生事件。如果每个事件都通过 JavaScript Bridge 传到 ArkTS,再由 ArkTS 调回另一个 DOM,跨运行时调度会放大延迟,也会增加事件乱序的机会。
源码和预览既然共享同一个页面,同步应该直接在 Web 内核中闭环。原生层只需要设置一个低频配置:同步是否开启。这个边界符合一条通用原则,高频连续交互应留在拥有实际视图的运行时,原生业务层只管理用户意图和持久状态。
Web 层只维护两个布尔值:
let syncScrollEnabled = true;
let synchronizingScroll = false;
syncScrollEnabled 是产品配置,决定是否同步。synchronizingScroll 是瞬时重入锁,表示当前目标视图的滚动由程序触发,不应再次反向传播。两个状态不能合并:关闭同步是用户长期选择,重入锁只持续一帧。
比例同步的数学模型
源码与预览的总高度通常不同。Markdown 标题、段落间距、表格、图片和代码块会改变渲染高度,因此不能直接把左侧 scrollTop 赋给右侧。OhMarkdown 使用可滚动范围的比例:
function synchronizeScroll(
source: HTMLElement,
target: HTMLElement
): void {
if (!syncScrollEnabled ||
currentMode !== 'split' ||
synchronizingScroll) {
return;
}
const sourceRange = source.scrollHeight - source.clientHeight;
const targetRange = target.scrollHeight - target.clientHeight;
if (sourceRange <= 0 || targetRange <= 0) {
return;
}
synchronizingScroll = true;
target.scrollTop = targetRange * (source.scrollTop / sourceRange);
window.requestAnimationFrame(() => {
synchronizingScroll = false;
});
}
设源码当前滚动位置为 S,源码可滚动范围为 Rs,预览可滚动范围为 Rt,目标位置就是 Rt * S / Rs。可滚动范围不是 scrollHeight,而是 scrollHeight - clientHeight。当滚动到底部时,scrollTop 最大只能到总高度减去视口高度;若直接用总高度计算,目标底部会永远差一个视口。
比例法有几个优点。它与文档长度无关,不需要把每个 Markdown 节点映射回源码行;源码和预览都能从顶部连续移动到底部;计算只包含常数次 DOM 读取和一次写入,适合高频滚动。它也有明确缺点:标题、图片或长代码块会造成局部高度差,百分之五十的源码位置不一定等于语义上的第五十个百分位。Alpha 阶段先提供稳定比例同步,后续再根据真实使用数据决定是否引入语义锚点。
边界检查防止无意义计算
同步函数首先检查三个条件。
同步开关关闭时立即返回。纯源码和纯预览模式也不需要同步,所以要求 currentMode === 'split'。最后检查重入锁,防止程序滚动再次触发反向同步。
随后计算两个范围,只要其中一个小于等于零就返回。短文档可能完全放进源码视口,sourceRange 为零;预览也可能只有一行。此时除法会产生无效比例,而用户也没有可同步的滚动。显式返回比依赖浏览器把 NaN 写入 scrollTop 更容易理解和测试。
还可以进一步对比例做 Math.min(1, Math.max(0, ratio)) 限制。正常浏览器会把 scrollTop 控制在有效范围,当前实现不需要额外夹紧;但若未来引入平滑动画、过度滚动或平台特有弹性效果,显式限制能防止负值和超过一的瞬时比例进入另一侧。
双向监听为什么会递归
事件绑定非常直接:
editor.scrollDOM.addEventListener(
'scroll',
() => synchronizeScroll(editor.scrollDOM, preview),
{ passive: true }
);
preview.addEventListener(
'scroll',
() => synchronizeScroll(preview, editor.scrollDOM),
{ passive: true }
);
用户滚动源码时,监听器设置预览 scrollTop;浏览器随后为预览派发 scroll,预览监听器又尝试设置源码。两边高度和像素取整不同,反向计算后的源码位置可能比原值差一个像素,于是再次触发事件。若没有重入控制,这种反馈可能持续数轮,在触控板高频事件下表现为抖动。
OhMarkdown 在写目标位置前把 synchronizingScroll 设为 true,等下一次动画帧再释放。在同一渲染帧中,由程序写入引发的目标滚动事件会看到锁并返回。为什么不是设置后马上恢复?因为 scroll 事件不保证在赋值语句内部同步派发,立即恢复可能让异步事件错过锁。为什么不是固定延迟一百毫秒?固定延迟会吞掉用户紧接着在另一侧进行的真实滚动,手感迟钝。requestAnimationFrame 把锁生命周期与浏览器渲染节奏对齐,是更合适的折中。
监听器使用 { passive: true },表明不会在回调中调用 preventDefault。浏览器可以更放心地处理滚动,不必等待 JavaScript 判断是否阻止。对于桌面触控板和滚轮,高频路径中的这类小约束会直接影响流畅度。
用户滚动和程序滚动的竞争
帧级布尔锁解决了最基本的递归,却不是所有同步滚动问题的终点。假设用户在左侧滚动的同一帧立刻把指针移到右侧并开始滚动,右侧第一个事件可能因为锁仍为 true 被忽略。这个窗口只有一帧,通常不可察觉,但它说明同步算法本质上在调解两个输入源。
更复杂实现可以记录 activeScrollSource、最近用户事件时间和指针所在区域,让最后主动操作的一侧成为主控。也可以在 wheel、触控板手势开始时切换主控,滚动结束后释放。当前产品先使用更小的状态机,因为模拟器与自动化路径没有出现明显竞争问题。工程上应先证明简单方案不足,再引入多事件源仲裁。
另一个细节是预览内容可能在滚动过程中重排。用户输入导致 Markdown 重新渲染,右侧 scrollHeight 变化,相同比例对应的新位置也会变化。当前编辑流程会重新生成预览,下一次滚动事件再使用新范围。若需要在无滚动输入时也保持语义锚点,可以在渲染前记录当前比例,渲染后恢复;图片异步加载则需要监听尺寸变化。当前离线预览禁止在线图片访问,减少了一类异步布局漂移,但本地图片仍可能影响高度。
同步开关属于原生产品状态
Web 内核暴露一个受限方法:
window.OhMarkdownEditor = {
// 省略其他接口
setSyncScroll: (enabled) => {
syncScrollEnabled = enabled;
}
};
ArkUI 侧将状态编码为 JavaScript 字面量:
private setEditorSyncScroll(): void {
this.runEditorScript(
`window.OhMarkdownEditor?.setSyncScroll(` +
`${JSON.stringify(this.syncScrollEnabled)})`
);
}
虽然布尔值手工拼接也不难,统一使用 JSON.stringify 能保持 Bridge 参数编码规则一致。当页面 onReady 时,原生层会重新发送当前开关;ArkWeb 因系统回收而重建后,不会悄悄回到默认值。
分栏模式下,工具栏显示按钮式 Toggle:
Toggle({
type: ToggleType.Button,
isOn: this.syncScrollEnabled
}) {
Text($r('app.string.sync_scroll'))
}
.accessibilityText($r('app.string.sync_scroll'))
.onChange((isOn: boolean) => {
this.syncScrollEnabled = isOn;
this.setEditorSyncScroll();
})
这个控件只在分栏模式出现,因为纯源码或纯预览时开关没有即时效果。使用 Toggle 而不是普通命令按钮,能明确表达持续的开关状态。设置了无障碍文本后,读屏也能理解控件用途。关闭同步后,两栏完全独立;应用不会在用户明确关闭后仍做“智能”跟随。
当前开关是工作台级状态,不随文档会话切换。这样用户对浏览方式的选择在标签之间保持一致。如果未来把它持久化到应用首选项,需要决定启动默认值、配置迁移和多窗口共享范围,而不能简单写一个全局文件。
分栏布局需要稳定的滚动容器
同步算法依赖编辑器 scrollDOM 与预览 #preview 分别拥有独立滚动范围。CSS 使用 Grid 建立两列,并对每个子项设置 min-width: 0 和 min-height: 0:
#workspace[data-mode='split'] {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
#editor,
#preview {
min-width: 0;
min-height: 0;
}
#preview {
display: none;
overflow: auto;
}
#workspace[data-mode='split'] #preview {
display: block;
}
Grid 子项默认最小尺寸可能受内容影响,长代码行会把列撑宽,导致页面级滚动或预览被挤出窗口。minmax(0, 1fr) 与 min-width: 0 允许两列在可用空间内真正收缩,确保滚动发生在预期容器,而不是整个 ArkWeb 页面。
窄窗口切换为上下两行:
@media (max-width: 760px) {
#workspace[data-mode='split'] {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
}
同步算法不关心横向还是纵向排列,因为它只读取两个容器的垂直范围。自动化测试把视口设置为 720×800,断言分栏变为一列两行且两侧都可见。鸿蒙 PC 自由窗口可能被用户缩到较窄宽度,布局断点不是手机适配附属项,而是桌面窗口能力的一部分。
鸿蒙 PC 模拟器中的分栏同步
下图来自 MateBook Pro 2in1 模拟器。左侧源码与右侧预览同时显示标题 Title 和 Target,工具栏的 Sync 处于开启状态。截图中的搜索面板仍可使用,说明同步滚动不是独占模式,而是与查找、标题和多标签共同工作的基础交互。

视觉一致并不能单独证明双向同步。自动化测试构造 120 个章节,让两侧都产生足够滚动范围,然后把源码滚到底部并主动派发事件:
await page.locator('.cm-scroller').evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event('scroll'));
});
await page.waitForTimeout(100);
expect(await page.locator('#preview').evaluate(
(element) => element.scrollTop > 0
)).toBe(true);
随后关闭同步,把预览滚回顶部,再断言源码仍保持大于零的位置:
await page.evaluate(() =>
(window as unknown as EditorTestWindow)
.OhMarkdownEditor.setSyncScroll(false)
);
await page.locator('#preview').evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event('scroll'));
});
const editorScrollTop = await page.locator('.cm-scroller')
.evaluate((element) => element.scrollTop);
expect(editorScrollTop).toBeGreaterThan(0);
这组断言同时覆盖了正向同步与开关关闭语义。反向同步可用镜像步骤验证。真实模拟器测试还应使用触控板惯性滚动、滚轮、小幅拖动滚动条和快速换边操作,因为合成 scroll 事件无法完全模拟输入设备节奏。
比例同步和语义同步如何选择
比例同步适合先建立稳定基线,但渲染高度差大的文档会出现局部偏差。例如源码中的一行图片语法在预览中可能占几百像素,图片之前比例仍接近,图片之后会突然变化。语义同步通常把源码行映射到预览块节点,在滚动时找到当前可见锚点,再在另一侧插值。
语义同步的代价包括:渲染器要为节点保留源码行映射;列表、引用、表格等嵌套块需要定义锚点;源码中间位置落在一个大代码块内时要计算块内比例;每次编辑后映射会变化;虚拟滚动或大文档模式还会改变可见节点。对于 Alpha 产品,如果没有证据表明比例法阻碍主要写作任务,直接实现复杂语义映射可能得不偿失。
可行的演进路径是混合策略:普通滚动使用比例,遇到可映射标题时吸附到最近标题锚点;或者只在用户停止滚动后进行一次低频校正。还可以统计两个视图中当前标题是否一致,以数据判断偏差是否值得优化。重要的是保留 setSyncScroll 接口和独立同步函数,使算法可以替换,而不影响 ArkUI 工具栏与编辑器其他能力。
大文档与性能边界
OhMarkdown 对五兆字符以上文档启用大文档保护模式,并强制停留在源码视图。原因不是同步乘除法昂贵,而是 Markdown 全量渲染、DOM 数量和预览更新可能造成明显内存压力。没有预览就没有同步滚动,函数通过 currentMode !== 'split' 自然退出。
在普通文档中,每个滚动事件读取两组 scrollHeight/clientHeight 并写一次 scrollTop。读取布局属性可能触发布局计算,尤其当 DOM 同时发生变化时。后续性能分析应在长表格、代码块和图片文档上记录帧时间,而不是只测空白文本。若出现布局抖动,可以在 ResizeObserver 中缓存范围,在动画帧中合并多次滚动事件,或采用 requestAnimationFrame 调度实际写入。
当前实现已用帧锁避免反馈,但没有显式节流源事件。浏览器通常会把滚动与渲染帧协调,是否增加节流应以鸿蒙 PC 真机数据为准。过早加入定时器可能让预览落后于触控板,破坏跟手感。
结语
双向同步滚动不是一句“监听 scroll 再赋值”就结束。可靠实现需要选择正确的运行时边界,使用可滚动范围而非总高度计算比例,处理短文档除零,阻止程序滚动反向反馈,保留用户关闭同步的权利,并确保分栏布局拥有稳定的独立滚动容器。
OhMarkdown 当前使用很小的算法完成了可测试闭环:源码到预览、预览到源码、同步开关和窄窗口布局都有明确行为。它没有假装比例同步能解决全部语义对齐,但为后续锚点映射保留了清晰替换点。对于鸿蒙 PC 桌面编辑器,这种先把高频交互做稳、再根据真实文档改进精度的路线,比一次性堆叠复杂映射更可控。
更多推荐




所有评论(0)