当前位置: 首页 > news >正文

鸿蒙 PC Markdown 编辑器即时渲染语法矩阵:结构降级、离线图片与光标可编辑性

鸿蒙 PC Markdown 编辑器即时渲染语法矩阵:结构降级、离线图片与光标可编辑性

即时渲染最容易被误解为“把 Markdown 变成富文本”。如果实现只追求视觉效果,确实可以先把正文渲染成 HTML,再让用户编辑 DOM,最后反向生成 Markdown。但是这条路线会把空格、换行、标记风格、引用缩进和链接写法重新排列。对于把 Markdown 文件交给 Git、静态站点、团队仓库或其他编辑器继续处理的用户,这种重排不是小瑕疵,而是文本事实来源发生了变化。

本文讨论另一条路线:在鸿蒙 PC 编辑器中始终保留同一个 CodeMirrorEditorState,借助 Lezer Markdown 语法树和 Decoration 改变屏幕呈现。用户看到的是标题、链接、引用、列表、代码和图片预览,磁盘中保存的仍然是原始 Markdown。光标进入结构后,必要标记立即恢复;语法未闭合、结构有歧义或超出支持范围时,局部直接显示源码。

对应工程仓库为 https://gitcode.com/VON-/codex_md_oh,本文基于提交1bf4b62。代码、测试数字和截图均来自该提交的实际实现。本文是一篇独立技术文章,不要求读者先了解项目的其他阶段。

即时渲染的正确验收对象是文本不变量

“标题看起来更大”只能证明 CSS 生效,不能证明编辑器可靠。即时渲染真正需要守住的不变量至少包括五项。

第一,源码、即时、分栏和预览必须共享同一个文本缓冲区。模式变化不创建另一份可写正文,也不能从预览 DOM 回填 Markdown。

第二,Decoration 只能改变显示,不能作为编辑事务写入正文。切换模式前后调用getDocument(),返回内容必须完全一致。

第三,光标必须能进入结构。如果链接目的地、强调标记或图片语法被永久隐藏,用户将无法修改它们。隐藏必须以当前选择区是否接触结构为条件。

第四,未闭合语法必须回退源码。解析器没有确认完整边界时,编辑器不能凭猜测隐藏字符。例如[链接](target.md缺少右括号,三个反引号代码围栏缺少结束标记,都应原样显示。

第五,大文档保护策略优先于视觉能力。精确 10 MiB 文档进入保护模式后,即时 Decoration 和隐藏的专业预览都必须关闭,保证保存和源码阅读仍可执行。

这五项约束决定了本次实现不会建立“富文本模型 + Markdown 模型”的双向同步,也不会在一个功能步骤里覆盖所有 Markdown 方言。语法矩阵按结构逐项扩展,每一项都包含正常、嵌套、光标进入和错误降级边界。

用语法树定义可以安全隐藏的边界

实现入口位于web-editor/src/instant-rendering.ts。插件读取 CodeMirror 当前状态中的 Lezer 语法树,只遍历可见范围,避免长文档滚动时为不可见节点创建无意义 DOM。

functioncreateInstantDecorations(view:EditorView,options:InstantRenderingOptions):DecorationSet{if(!view.state.field(instantRenderingEnabled)){returnDecoration.none;}constranges:Array<Range<Decoration>>=[];consttree=syntaxTree(view.state);for(constvisibleRangeofview.visibleRanges){tree.iterate({from:visibleRange.from,to:visibleRange.to,enter:(node)=>{// 每种结构只在解析器确认的节点边界内生成 Decoration。}});}returnDecoration.set(ranges,true);}

这里没有把 Markdown 交给正则表达式逐行替换。正则很难正确处理嵌套强调、引用中的列表、链接标题、转义字符和未闭合结构。语法树节点已经给出了结构名称、起止偏移和父子关系,Decoration 只需要在这些边界内工作。

选择区判断同样保持简单。只要任意选择范围与节点相交,就视为用户正在编辑该结构,不隐藏必要标记。

functionselectionTouches(view:EditorView,from:number,to:number):boolean{returnview.state.selection.ranges.some((range)=>range.from<=to&&range.to>=from);}

这个规则故意偏保守。即使光标落在结构边界,也宁可多显示一次源码标记,不能让用户无法定位。即时渲染的价值是减少视觉噪声,不是剥夺源码编辑能力。

ATX 与 Setext 标题需要不同的显示策略

ATX 标题使用行首#,Setext 标题使用下一行的===---。两者在语法树中分别表现为ATXHeading1ATXHeading6SetextHeading1SetextHeading2。统一的层级解析如下。

functionheadingLevel(nodeName:string):number|undefined{constmatch=/^(?:ATX|Setext)Heading([1-6])$/.exec(nodeName);returnmatch?Number.parseInt(match[1],10):undefined;}

标题正文通过行 Decoration 获得字号、字重和分隔线;HeaderMark只有在选择区没有接触标题时才隐藏。Setext 的标记单独占一行,如果简单设置成零高度,编辑器 gutter 中的行号会重叠。设备视觉验收第一次就发现了这个问题:6 像素的折叠高度无法容纳行号。最终版本保留 18 像素稳定高度,既降低标记行存在感,也不破坏行号定位。

.cm-line.cm-instant-setext-marker, .cm-line.cm-instant-code-fence{min-height:18px;height:18px;overflow:hidden;line-height:18px;}

这说明桌面编辑器不能只看正文 DOM。源码行号、折叠标记、滚动定位和选择映射都属于同一交互系统。过度压缩某一行,视觉上可能“更像排版软件”,却会制造定位重叠和点击误差。

链接只隐藏已经确认完整的目标

普通行内链接[文本](target.md)、带标题链接和显式引用链接可以安全收起目标部分,但未闭合链接不能处理。Lezer 对[链接](target.md可能只识别出前面的[链接]节点;如果看到两个LinkMark就直接隐藏,左方括号会消失,而后面的不完整目标仍留在屏幕上。

最终实现增加了完整目标判断:行内链接必须拥有完整的括号标记,引用链接必须拥有LinkLabel。只有条件成立时,才隐藏开头方括号以及从文本闭括号到节点末尾的目标部分。

constlinkMarks=[];lethasReferenceLabel=false;letchild=node.node.firstChild;while(child){if(child.name==='LinkMark'){linkMarks.push(child);}elseif(child.name==='LinkLabel'){hasReferenceLabel=true;}child=child.nextSibling;}consthasCompleteTarget=linkMarks.length>=4||hasReferenceLabel;if(linkMarks.length>=2&&hasCompleteTarget&&!selectionTouches(view,node.from,node.to)){ranges.push(Decoration.replace({inclusive:false}).range(linkMarks[0].from,linkMarks[0].to));ranges.push(Decoration.mark({class:'cm-instant-link'}).range(linkMarks[0].to,linkMarks[1].from));ranges.push(Decoration.replace({inclusive:false}).range(linkMarks[1].from,node.to));}

自动链接<https://example.com>隐藏两侧尖括号并保留 URL;普通裸 URL 只增加链接颜色和下划线,不改写内容。链接在即时模式中只是显示为链接,不直接发起网络访问。预览中的本地跳转仍经既有原生命令和授权边界处理,外部链接保持受限。

下图来自 MateBook Pro 2in1 鸿蒙模拟器。光标位于空白行时,Setext 标记、链接目标、引用标记、代码围栏和图片语法按规则收起,行号没有重叠。

当光标进入链接结构时,完整[本地文档](docs/guide.md)立即恢复,其他结构仍保持即时显示。这不是单独维护的“编辑视图”,只是同一语法树在选择变化后重新计算 Decoration。

图片预览复用受限 Bridge 而不是开放网络

图片是即时渲染中风险最高的结构之一。直接把 Markdown 的src写入<img>会带来两个问题:本地用户路径不能被 ArkWeb 任意读取,远程 URL 又可能在用户不知情时发出网络请求。

本次实现只复用已有的受限图片链路。Markdown 图片节点被替换为固定尺寸 Widget;如果当前文档会话已经缓存了对应 Blob URL,就显示真实图片。没有缓存时,只向requestImageSource提交原始 Markdown 路径。主模块继续执行两段式相对路径校验、授权目录读取、图片 MIME 白名单、8 MiB 上限、64 KiB 分块传输和会话隔离。

exportinterfaceInstantRenderingOptions{resolveImageSource?:(markdownPath:string)=>string|undefined;requestImageSource?:(markdownPath:string)=>void;}constpreviewUrl=options.resolveImageSource?.(source);if(!previewUrl){options.requestImageSource?.(source);}ranges.push(Decoration.replace({inclusive:false,widget:newInstantImageWidget(source,altText,previewUrl,node.from,node.to)}).range(node.from,node.to));

外部 URL 无法通过相对路径校验,因此不会进入 Bridge,也不会加载网络图片。Widget 显示固定的替代文本占位,截图中的“外部图片”就是该安全降级。对于有效的本地工作区图片,Bridge 返回分块数据后创建 Blob URL,再通过显式状态 effect 刷新即时 Decoration。

functionrefreshInstantImages(sessionId:string):void{if(sessionId===activeSessionId&&currentMode==='instant'){editor.dispatch({effects:refreshInstantRendering.of(null)});}}

Widget 使用固定的 520 x 220 像素上限,图片以object-fit: contain显示。这样加载完成前后不会突然把编辑器内容推开。用户点击图片 Widget 时,选择区移动到图片源码内部,下一次 Decoration 计算会撤掉 Widget并恢复![alt](path),从而继续编辑替代文本和路径。

引用与列表优先保留结构语义

引用节点的视觉目标不是生成第二份 HTML blockquote,而是在编辑器行上增加左边界和文字颜色。每一个QuoteMark在结构未被选择时隐藏,Blockquote覆盖的行获得同一类名。嵌套强调、链接和行内代码仍由它们自己的节点处理。

列表则采用更保守的策略。ListItem提供轻微纵向间距,ListMark保留在文本中并使用主题色和加粗。无序列表没有把-替换为私有项目符号,有序列表也不伪造自动编号。用户仍能清楚看到源文件使用了哪一种标记,同时获得比源码模式更稳定的层级视觉。

这种保守处理还有一个现实理由:列表延续、Tab 缩进和任务列表编辑属于后续结构化编辑事务。当前阶段只改变显示,不应提前改变 Enter、Tab 或撤销语义。视觉 Decoration 和结构编辑命令分开验收,可以明显缩小数据损坏的风险面。

行内代码与围栏代码块采用两级降级

完整的行内代码节点拥有两个CodeMark。结构未被选择时隐藏反引号,内容使用等宽字体、浅色背景和细边框;光标进入时反引号恢复。未闭合反引号没有完整节点,因此保持源码。

围栏代码块首先确认至少存在开始和结束两个CodeMark。如果只有开始围栏,整个节点不生成即时样式。完整结构的所有行获得统一等宽背景,开始行的语言信息与结束围栏在光标离开时隐藏,标记行保留 18 像素稳定高度。光标进入代码块后,围栏和语言标识全部恢复。

if(node.name==='FencedCode'){constcodeMarks=[];letchild=node.node.firstChild;while(child){if(child.name==='CodeMark'){codeMarks.push(child);}child=child.nextSibling;}if(codeMarks.length<2){returnfalse;}addLineRangeDecorations(ranges,view,node.from,node.to,'cm-instant-code-block');}

即时编辑器没有在代码块内部运行 Highlight.js。专业高亮仍属于预览管线,带有异步取消和二次净化。即时模式选择低成本等宽样式,避免每次输入都启动另一套代码高亮 DOM,也避免隐藏编辑器内核本身的 Markdown 语法状态。

为什么需要显式刷新图片而不刷新全部预览

CodeMirror 插件在模式变化、正文变化、选择变化和视口变化时重算可见 Decoration。本地图片读取是异步事件,完成时正文没有变化,所以增加了专用refreshInstantRenderingeffect。这个 effect 只让 Decoration 重算,不修改文档,也不进入撤销历史。

constrefreshRequested=update.transactions.some((transaction)=>transaction.effects.some((effect)=>effect.is(refreshInstantRendering)));if(modeChanged||refreshRequested||update.docChanged||update.selectionSet||update.viewportChanged){this.decorations=createInstantDecorations(update.view,options);}

即时模式仍然不会在每次输入时生成隐藏的 markdown-it、KaTeX、Mermaid 和 Highlight.js 完整预览。只有分栏和阅读模式需要专业预览。这个边界对鸿蒙 PC 的输入延迟和 ArkWeb 内存尤为重要:用户选择即时写作,不应在后台承担双栏渲染的全部成本。

自动化语料覆盖正常、交互与失败路径

本轮新增三项 Playwright 用例,使 Web 全量从 47 项增加到 50 项。

第一项使用 Setext 标题、行内链接、自动链接、引用、无序列表、有序列表、行内代码和 TypeScript 围栏代码块,验证对应类名、隐藏结果和getDocument()完全相等。

第二项使用一个本地图片和一个远程图片。测试 Bridge 只收到本地Note.assets/instant.png,本地图片最终使用blob:URL,远程图片只显示替代文本。点击本地 Widget 后,图片源码恢复而文档内容不变。

第三项输入未闭合链接与未闭合代码围栏,验证两者都显示源码,且不存在代码块即时类名。这项用例直接防止“解析一半也隐藏一半”的错误。

定向测试最终为 6/6,全量 Playwright 为 50/50。精确 10 MiB Chromium 保护模式回归本轮记录 321 ms,远低于三秒门槛,但这个数字只代表当前本机 Web 环境,不能替代鸿蒙 PC Release 真机的读取、Bridge、输入 P95 和内存结果。

鸿蒙模拟器验证暴露了自动化看不到的行号问题

最终 Debug HAP 安装到 MateBook Pro 2in1 模拟器。通过设备 UI 自动化输入精确多行 Markdown,切换“源码/即时”,把光标分别放在空白行和链接结构中,再截取 3120 x 2080 应用画面。

第一次视觉检查发现隐藏 Setext 标记行和代码围栏行只有 6 像素,正文虽然没有重叠,gutter 中相邻行号却挤在一起。实现随后把稳定高度调整为 18 像素,重新执行全量构建、安装和截图。最终截图中第 14、15、16 行保持清晰,代码背景、图片占位和状态栏也没有相互遮挡。

最终./scripts/verify-local.sh通过,包含 Playwright 50/50、生产单 HTML、Debug HAP、ArkTSUnitTestBuild和差异检查。最终 ohosTest HAP 重新构建、安装并执行,结果为 11/11,Failure 0、Error 0,总耗时 2520 ms。

产物事实如下:

  • Debug HAP:8,558,158 字节,SHA-256ba78366e06b46e490f96174c883f3a07fcccdb052e4a7526b278b50a176a7473
  • ohosTest HAP:9,335,601 字节,SHA-256d2cc792a4da230274a7f5d09c4b7ecce8da6543a7775a72847bc6bdd4b10689f
  • 语法矩阵截图:SHA-25635ba49beb53b3a0b458109205361b7e613cff3786604ff710b7bf7a52a138dd3
  • 链接显标截图:SHA-25649cc97f2c33d0311bf1da7b8d949d2c4d150cddd9f835c03b944b428560d6eae

当前语法矩阵的明确边界

当前即时模式已经覆盖 ATX H1-H6、Setext H1-H2、粗体、斜体、完整行内链接、显式引用链接、自动链接、裸 URL、图片、引用、无序/有序列表、行内代码和完整围栏代码块。

它没有把表格、任务复选框、删除线、Front Matter、HTML 块、公式和 Mermaid 伪装成已完成能力。这些结构继续显示源码,专业排版可在分栏或阅读模式查看。即使已覆盖的类型遇到未闭合或无法确认边界,也会局部回退源码。

本地图片可以显示真实 Blob 预览,外部图片默认不会联网,只显示稳定占位。图片点击显标已经由自动化覆盖,鸿蒙模拟器截图展示的是外部图片安全降级。真实工作区图片、物理键盘、触控板、输入法组合和 Release 性能仍需要鸿蒙 PC 真机矩阵复核。

工程结论

即时渲染的竞争力不在于“隐藏了多少 Markdown 符号”,而在于隐藏之后仍能可靠编辑、撤销、保存和跨软件交换原文件。基于同一EditorState的 Decoration 路线把显示层与文本事实来源分开,使每种语法都可以独立增加、独立降级和独立测试。

对鸿蒙 PC 编辑器而言,这种路线还保留了平台侧优势:ArkUI 继续负责窗口、文件授权和系统能力,ArkWeb 只处理本地编辑器;图片仍通过最小权限 Bridge 读取,远程内容不会因为即时显示而突破离线策略;大文档仍能回到源码模式。

下一阶段将从“结构显示”进入“结构编辑辅助”,重点是列表延续、缩进、自动配对和表格行列命令。届时验收对象不再只是 Decoration,而是每个操作能否形成一次可整体撤销的 CodeMirror 事务,并保持空格、换行和 Markdown 标记不被无意重排。

http://www.cnnetsun.cn/news/3613648.html

相关文章:

  • Java调用Windows TTS实战:Jacob库原理、配置与工程化指南
  • AI+PLUS+InVEST融合方案在生态规划中的应用
  • AI驱动智能办公:提升协作效率的技术实践
  • 深度学习对抗训练实战:原理、技术与工业应用
  • 震动整个AI圈!前所未有的人工智能失控事故!中方救场,OpenAI承认其模型测试失控
  • AI驱动的架构映射智能体:从业务需求到技术实现
  • 西安共享羽毛球馆系统开发实战:从零搭建到上线全指南
  • C++时间处理基石:<ctime>库深度解析与实战避坑指南
  • C++高性能编程:线程池与协程调度器协同优化阻塞任务
  • LangChain SQL查询代理:自然语言操作数据库实践
  • C++实战:从零构建足球管理系统,掌握面向对象与数据持久化
  • OpenCV图像处理实战:工业级算法与优化技巧
  • 大模型Token成本优化六大策略与实战案例
  • 企业AI培训实战:岗位适配与效果提升策略
  • C语言实现HTTP分块编码:从协议原理到高性能网络编程实战
  • 一个基于模形式紧致化机制的宇宙学常数与精细结构常数关联模型
  • AI矩阵系统如何提升实体商业转化率
  • C++智能建筑能源管理系统:从仿真测试到性能优化的工程实践
  • VC++自绘控件开发指南:从消息机制到双缓冲绘图实战
  • C++文件流在SLAM项目中的核心应用与性能优化实践
  • 谷歌AI Agent技术演进与核心组件解析
  • 移动端URP渲染管线与方舟引擎结合的性能调优实战
  • Java在企业级AI开发中的优势与实践
  • 医疗AI大模型核心技术解析与落地实践
  • KNIME制造业AI实战:可视化工作流解决质量检测与预测性维护
  • 数字化打卡工具与行为心理学:42天习惯养成实战
  • 2026年开会如何共享屏幕?4种会议室投屏方案横评实测,真正好用的只有这款
  • MSPM33看门狗定时器原理与应用:独立与窗口看门狗配置指南
  • Cursor AI在测试开发中的应用:从自动化脚本到智能测试伙伴
  • SAR ADC评估套件实战指南:从硬件设计到性能测试