从 IPC 到 PDF 导出:Electron 跨平台 Markdown 编辑器开发实践
简介:DBeditor 是面向开发者、技术写作者与前端学习者的跨平台 Markdown 编辑器项目,强调在 Linux、macOS 与 Windows 上保持统一体验,并以简洁扁平、大量留白的界面减少菜单干扰。它适合想了解桌面端编辑器如何组织界面、路由与前端模块的读者,也可作为轻量工具的参考实现。压缩包共 61 个文件、约 499KB,主要由 JavaScript 脚本、CSS 样式、Jade 模板、JSON 配置、PNG 素材和 Markdown 文档构成,可梳理应用入口、帮助页、欢迎页及组件目录的协作关系。已有 927 人学习下载,读者可获取项目结构、配置写法、模板组织与素材管理思路,用于二次开发、课程实践或前端项目参考。
1. 需求倒推:跨平台 Markdown 编辑器到底该做成什么样
写技术文档的人都遇到过这种尴尬:左边源码改了一个字,右边预览猛地跳回顶部,你得重新滚回原位,再花几秒找回落点。DBeditor 就是冲着这类细小却高频的摩擦去的——它是一款用 JavaScript 开发的跨平台 Markdown 编辑器,同一套代码跑在 Windows、macOS、Linux 上,左编辑右预览、滚动联动、图片粘贴即插入、一键导出 HTML 与 PDF。它不适合拿来跟成熟商业写作软件比功能丰度,适合两类人:想搞清楚桌面端 Markdown 编辑器内部链路怎么走的开发者,以及手上已经有个 Electron 壳子、想照着一份可运行代码补全编辑、文件读写、导出这几块骨架的人。下面按“能跑起来、能看懂、能上手改”的顺序拆。
2. 工程骨架:主进程、preload 与渲染层怎么切
2.1 为什么是 Electron 加原生 JavaScript
DBeditor 的技术底座没有太多悬念:Electron 负责窗口和系统调用,渲染层就是纯 Web 技术栈。选它的核心理由不是“流行”,而是 Markdown 编辑器这个场景本身长得就像网页——左侧一个文本编辑区,右侧一个 DOM 渲染区,中间隔一层字符串转换。用 Web 技术做,预览样式调起来就是改 CSS,代码块高亮直接借 highlight.js,导出 PDF 直接调 Chromium 自带的打印管线,几乎不用自己造轮子。
放弃“全前端框架”那套(React/Vue 全家桶)是另一个刻意取舍。DBeditor 的界面状态很浅:一个文件路径、一个脏标记、一个主题变量、一个预览区。为这点状态引入路由、状态管理、构建链,收益是负的。所以它用的是原生 DOM 加模块化 ES Module,document.querySelector直接拿节点,事件用addEventListener挂,打包工具只做最基础的一层合并与压缩。这套做法在页面动辄几十个组件的后台系统里不成立,但放在一个单窗口桌面编辑器上,恰恰是调试成本最低的。
也不用 Tauri 或原生 GUI 方案,理由很现实:跨平台 WebView 版本差异带来的渲染不一致,在 Markdown 预览这种重排版的场景里会被放大——表格边框、字体回退、滚动条宽度,每个平台一个样。Electron 自带同一份 Chromium,预览在我机器上什么样,到别人机器上基本还是什么样,这个确定性值钱。
2.2 目录结构:四个角色各自的边界
工程按“主进程 / 桥 / 渲染层 / 构建产物”切成四块,边界划得很死,任何一个文件放错位置在 code review 里都会被拎出来。
dbeditor/ ├── package.json ├── electron/ │ ├── main.js 主进程入口:窗口、菜单、生命周期 │ ├── preload.js 唯一桥接层,白名单暴露 API │ └── ipc/ │ ├── file.js file:open / file:save / file:save-as │ └── export.js export:html / export:pdf ├── src/ │ ├── index.html 单页结构,编辑区 + 预览区两个容器 │ ├── renderer.js 渲染层入口,串起编辑器与预览 │ ├── editor/ │ │ ├── createEditor.js CodeMirror 实例化 │ │ └── extensions.js 快捷键、主题、行号等扩展清单 │ ├── markdown/ │ │ ├── parser.js markdown-it 实例与配置 │ │ └── plugins.js 锚点、任务列表等自定义插件 │ ├── styles/ │ └── assets/ └── build/ 图标、安装包资源主进程只干三件事:开窗口、注册 IPC、管菜单。它不认识 Markdown,也不该认识——一旦有人在main.js里写字符串解析,后面想换渲染库就得动两个进程,改造成本翻倍。preload 是唯一的桥,只暴露具体方法名,不透传ipcRenderer。渲染层拿不到require,也拿不到process,所有系统能力都得走显式声明过的那几个通道。
2.3 窗口创建与启动链路
窗口创建这段代码短,但每个参数都踩过坑,注释里写清楚比写文档管用。
// electron/main.js —— 主进程只做窗口和系统能力,不碰任何 Markdown 逻辑 const { app, BrowserWindow, ipcMain, dialog } = require('electron') const path = require('node:path') const { registerFileHandlers } = require('./ipc/file') const { registerExportHandlers } = require('./ipc/export') let mainWindow = null function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, minWidth: 720, show: false, // 先隐藏,等 ready-to-show 再显示,避免白屏闪烁 backgroundColor: '#1e1e1e', // 与深色主题对齐,减少首帧闪白 webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, // 渲染层的 window 与 preload 隔离 nodeIntegration: false, // 渲染层不允许直接 require sandbox: false, // preload 里需要少量 Node API,按需放开 spellcheck: false // 关掉拼写检查,中文场景下红线很吵 } }) mainWindow.loadFile(path.join(__dirname, '../src/index.html')) mainWindow.once('ready-to-show', () => mainWindow.show()) } app.whenReady().then(() => { registerFileHandlers(ipcMain, dialog) registerExportHandlers(ipcMain, dialog) createWindow() // macOS 点 Dock 图标时应重建窗口,而不是重启进程 app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })contextIsolation: true加nodeIntegration: false是这套架构的安全底线:渲染层里的任何脚本(包括用户粘贴进来的、预览区可能误执行的)都摸不到 Node 的文件系统。show: false配ready-to-show是消除首屏白闪的标准做法,少了这一步,冷启动会闪一下纯白再变深色,体验上很掉分。sandbox: false属于妥协项,因为 preload 里要读一点环境信息,如果项目后续能把这部分挪走,建议改回true。
preload 的写法决定了整个项目的攻击面大小。常见翻车是图省事写成contextBridge.exposeInMainWorld('api', { ipcRenderer }),等于把整个 IPC 体系递给渲染层,隔离白做了。
// electron/preload.js —— 唯一的桥,白名单式暴露,不做通用透传 const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('dbeditor', { // 每个方法对应主进程里一个明确的 handler openFile: () => ipcRenderer.invoke('file:open'), saveFile: (payload) => ipcRenderer.invoke('file:save', payload), saveAs: (payload) => ipcRenderer.invoke('file:save-as', payload), exportHtml: (payload) => ipcRenderer.invoke('export:html', payload), exportPdf: (payload) => ipcRenderer.invoke('export:pdf', payload), // 菜单事件用订阅模式,返回取消订阅函数,避免页面重载后监听叠加 onMenu: (handler) => { const listener = (_event, action, arg) => handler(action, arg) ipcRenderer.on('menu:action', listener) return () => ipcRenderer.removeListener('menu:action', listener) } })参数层面值得说的是onMenu返回的那个取消订阅函数。渲染层如果在热更新或重新挂载时不调用它,同一个菜单动作会被触发多次,表现为“按一次 Ctrl+S 保存了三遍”,日志里看像是文件系统抽风,其实是监听器堆叠。这是我在别的 Electron 项目里吃过的亏,所以这里直接从 API 设计上留好后悔药。
3. 编辑核心:CodeMirror 实例与 markdown-it 渲染管线
3.1 初始化编辑器:状态与扩展分开管
编辑区选 CodeMirror 6,不是因为它名气大,而是它的“状态不可变 + 扩展组合”模型非常适合做主题切换和快捷键重绑——换个主题只是重配一个扩展数组,不用销毁重建 DOM。
// src/editor/createEditor.js import { EditorState } from '@codemirror/state' import { EditorView, keymap, lineNumbers, highlightActiveLine } from '@codemirror/view' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { languages } from '@codemirror/language-data' import { buildExtensions } from './extensions.js' export function createEditor({ parent, doc, onChange }) { const state = EditorState.create({ doc, extensions: [ lineNumbers(), // 行号槽 highlightActiveLine(), // 当前行底色 history(), // 撤销栈,默认 100 步 keymap.of([...defaultKeymap, ...historyKeymap]), markdown({ base: markdownLanguage, codeLanguages: languages }), // 围栏代码块内部按语言高亮,依赖 language-data 按需加载 EditorView.lineWrapping, // 长行软换行,写文档必须开 ...buildExtensions(), // 主题、自定义快捷键从这里注入 EditorView.updateListener.of((update) => { if (update.docChanged) onChange(update.state.doc.toString()) }) ] }) return new EditorView({ state, parent }) }updateListener里那个docChanged判断不能省。CodeMirror 的 update 不只由输入触发,光标移动、选区变化、视口滚动都会走一遍监听器,如果不判断就直接调onChange,等于每次点一下鼠标都整篇重渲染一次预览,性能会莫名其妙地塌下来。lineWrapping也是必选项,Markdown 里长段落很常见,不开软换行就得横向滚动,写作体验直接崩掉。
buildExtensions()是留给二次开发的注入点,主题、自定义快捷键、代码折叠都从这里进来。把扩展清单单独抽一个文件的好处是,换主题时只改这一个文件,不用动编辑器初始化的主流程。
3.2 markdown-it 的配置与 XSS 防线
渲染侧用 markdown-it,配置项不多但每一项都影响安全与观感。
// src/markdown/parser.js import MarkdownIt from 'markdown-it' import hljs from 'highlight.js' import { anchorPlugin, taskListPlugin } from './plugins.js' export const md = new MarkdownIt({ html: false, // 关键:不解析裸 HTML,粘贴进来的 <script> 不会执行 linkify: true, // 裸链接自动转 a 标签 typographer: true, // 引号、省略号做排版替换 breaks: false, // 单换行不强制 <br>,遵循 CommonMark highlight(code, lang) { if (lang && hljs.getLanguage(lang)) { try { // 运行时调用,此时 md 已完成初始化,闭包引用安全 return `<pre class="hljs"><code>${hljs.highlight(code, { language: lang }).value}</code></pre>` } catch (_) { // 语言存在但高亮抛错时落到兜底,不让整篇预览挂掉 } } return `<pre class="hljs"><code>${md.utils.escapeHtml(code)}</code></pre>` } }) md.use(anchorPlugin) // 标题生成 id,支持页内跳转 md.use(taskListPlugin) // - [x] 渲染成复选框html: false是硬性要求。Markdown 编辑器天生要处理用户粘贴进来的任意文本,只要开了裸 HTML,别人发你一段带<img onerror=...>的片段,粘进编辑器预览就执行了。桌面端的渲染层虽然被contextIsolation挡了一层,但仍能发起网络请求、读取页面内的敏感内容,这个口子不能开。
typographer会把直引号转成弯引号、--转成破折号。有人觉得这是“自作聪明”,但在中文技术文档里,它确实让排版好看一截。如果是要处理代码片段多的文档,可以按文件类型动态关掉这个开关——这是后续可以加的改造点。
高亮回调里md.utils.escapeHtml是防止语言不支持时把原始代码直接吐进 DOM。少了这行,一段包含<div>的普通文本块会被浏览器当标签解析,预览区样式直接乱套。
3.3 输入防抖与同步滚动
编辑区一变,预览就得跟着变,但“跟着变”不等于“每次按键都变”。文档上万字时,一次全量md.render加上innerHTML替换,主线程要卡几十毫秒,连着输入就是明显的掉帧。
// src/renderer.js —— 输入防抖 + 百分比同步滚动 const previewPane = document.querySelector('#preview') const editorScroller = document.querySelector('.cm-scroller') function debounce(fn, wait) { let timer = null return (...args) => { clearTimeout(timer) timer = setTimeout(() => fn(...args), wait) } } // 80ms 是个经验值:小于 60ms 基本等于没防抖,大于 150ms 会明显感觉预览"慢半拍" const renderPreview = debounce((text) => { previewPane.innerHTML = md.render(text) }, 80) // 滚动同步用百分比映射,不做逐行对齐,实现简单且不会因图片高度变化而错位 let syncing = 'none' editorScroller.addEventListener('scroll', () => { if (syncing === 'preview') return syncing = 'editor' const max = editorScroller.scrollHeight - editorScroller.clientHeight || 1 const ratio = editorScroller.scrollTop / max const target = previewPane.scrollHeight - previewPane.clientHeight previewPane.scrollTop = ratio * target // 下一帧再解锁,避免反向滚动事件形成循环 requestAnimationFrame(() => { syncing = 'none' }) })syncing这个标记是整段代码的核心。预览区被程序改scrollTop时也会触发自己的 scroll 事件,如果不加锁,两边就会互相推着滚,表现为“滚一下,页面自己往下窜一大截”,很多人第一反应以为是渲染错乱,其实是事件回声。
百分比映射而不是逐行对齐,是个明确的取舍:逐行对齐要在 DOM 里给每个块级元素打行号标记,改一处渲染逻辑就得重算一遍映射表,维护成本高;而且图片、表格加载后高度会变,行号映射很快失效。百分比方案在长文档里会有轻微错位,但永远不会崩,属于“够用且不会翻车”的选择。
4. 文件与导出:IPC 通道、原子保存和 PDF 打印链路
4.1 用 invoke/handle 替代 remote:一条文件读写通道
主进程里所有文件操作走ipcMain.handle,渲染层用invoke调,天然带上 Promise 语义,异常能一层层抛回来。
// electron/ipc/file.js const fs = require('node:fs/promises') const path = require('node:path') function registerFileHandlers(ipcMain, dialog) { ipcMain.handle('file:open', async () => { const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'txt'] }] }) if (canceled || filePaths.length === 0) return null const filePath = filePaths[0] const stat = await fs.stat(filePath) // 大文件拦截:超过 2MB 的纯文本,编辑器渲染会非常吃力 if (stat.size > 2 * 1024 * 1024) { return { filePath, content: '', tooLarge: true } } return { filePath, content: await fs.readFile(filePath, 'utf8') } }) ipcMain.handle('file:save', async (_event, { filePath, content }) => { if (!filePath) return { ok: false, reason: 'no-path' } const dir = path.dirname(filePath) const tmp = path.join(dir, `.${path.basename(filePath)}.dbedit-tmp`) // 先写临时文件再 rename,避免写到一半断电导致原文件被截断 await fs.writeFile(tmp, content, 'utf8') await fs.rename(tmp, filePath) return { ok: true } }) } module.exports = { registerFileHandlers }file:open里那个 2MB 的判断不是拍脑袋。CodeMirror 处理几万行纯文本没问题,但 markdown-it 每次全量渲染加上innerHTML替换,几万行会直接把主线程按住好几秒,用户体感就是“点了打开没反应”。与其让界面假死,不如直接告诉用户“这文件太大了,建议用别的工具看”。这条判断后来被我挪到了配置里,做成可调阈值。
file:save走的是临时文件加rename的老套路。原因很直白:writeFile打开目标文件时会先清空,如果这个时候进程被强杀或者机器断电,原文件就变成一个 0 字节的空壳,写了几小时的东西全没了。rename在同一个分区内是原子操作,要么新内容整体生效,要么原文件纹丝不动。临时文件命名前面加点,是让 Unix 系的文件管理器默认隐藏它,不至于用户目录里一堆垃圾文件。
4.2 脏标记与自动保存的兜底
保存逻辑本身不难,难的是“什么时候该存”。DBeditor 用两个信号:一个是编辑器内容与上次落盘内容不一致的脏标记,另一个是窗口失去焦点。
// src/renderer.js —— 自动保存策略 let currentPath = null let lastSaved = '' let autoSaveTimer = null function markDirty(text) { const dirty = text !== lastSaved document.title = dirty ? `● ${basename(currentPath) || '未命名'}` : basename(currentPath) || '未命名' if (autoSaveTimer) clearTimeout(autoSaveTimer) // 有路径才自动存,未命名文档必须等用户显式指定位置 if (dirty && currentPath) { autoSaveTimer = setTimeout(() => persist(text), 2000) } } async function persist(text) { const res = await window.dbeditor.saveFile({ filePath: currentPath, content: text }) if (res.ok) lastSaved = text } // 切走窗口时立刻落盘一次,不等 2 秒 window.addEventListener('blur', () => { if (autoSaveTimer) clearTimeout(autoSaveTimer) const text = view.state.doc.toString() if (text !== lastSaved && currentPath) persist(text) })标题栏那个圆点标记是个小设计但很实用。用户在多窗口之间来回切的时候,靠标题就能判断哪个文件还没存,不用挨个点开看。blur事件里手动清一次定时器再立即保存,是为了覆盖“用户敲完最后一个字马上切到浏览器”这个高频场景——只靠 2 秒防抖,切走那一瞬间的改动可能还没落盘。
未命名文档不做自动保存,是刻意的限制。没有路径就没法写文件,硬要自动存只能落到临时目录,后续用户找不到文件反而更慌。这个取舍在文档里写明了:“新建文档请先 Ctrl+S 指定位置”。
4.3 导出 HTML 与 PDF 的三段式流程
导出分两条路,HTML 走字符串拼接,PDF 走 Chromium 的离屏打印。
// electron/ipc/export.js const { BrowserWindow } = require('electron') const fs = require('node:fs/promises') const path = require('node:path') const os = require('node:os') async function renderToPdf(html, outputPath) { // 大文档走临时文件,data URL 有长度上限,超了会静默失败 const tmpHtml = path.join(os.tmpdir(), `dbeditor-${Date.now()}.html`) await fs.writeFile(tmpHtml, html, 'utf8') const win = new BrowserWindow({ show: false, webPreferences: { offscreen: true, javascript: false } }) await win.loadFile(tmpHtml) const pdf = await win.webContents.printToPDF({ printBackground: true, // 不开这个,代码块底色全丢 pageSize: 'A4', margins: { top: 0.6, bottom: 0.6, left: 0.7, right: 0.7 } }) await fs.writeFile(outputPath, pdf) win.destroy() await fs.unlink(tmpHtml).catch(() => {}) }printBackground: true是最容易漏的一项。默认值是false,导出来的 PDF 里代码块、引用块、表格隔行底色全部变成白底,看起来像样式丢了,其实是打印设置的问题。javascript: false是为了防止待打印页面里有脚本在渲染过程中改 DOM,导致内容跟预览对不上,导出件和屏幕显示不一致是最难排查的一类问题。
offscreen: true配合show: false让整个打印过程不出现在任务栏里。早期版本忘了设offscreen,用户点导出会看到一个白窗口一闪而过,虽然功能正常,但观感像程序崩了一下。
大文档必须落临时文件再用loadFile,这是我用 data URL 踩出来的教训。Chromium 对 data URL 有长度限制,一篇带大量图片 base64 的文档轻松超限,表现是loadFile不报错但页面是空的,导出的 PDF 只有一页白纸,排查起来完全没有线索。落临时文件后长度不受限,还能在调试时直接打开那个 HTML 看渲染结果。
5. 避坑与排查:打包、输入法、大文件和字体上的五个真实翻车点
5.1 开发环境一切正常,打包后白屏
现象:npm run dev里编辑器和预览都好用,electron-builder打完包双击,窗口出来了但一片白,DevTools 里显示资源 404。
原因:开发时用相对路径加载src/index.html,脚本引用写的是./renderer.js;打包后文件被塞进app.asar,loadFile的基准路径变了,相对路径解析到了file:///根目录。渲染层的 ES Module 引用如果还是裸路径,浏览器直接按模块解析失败处理,静默不执行。
解决:loadFile用path.join(__dirname, '../src/index.html')拼绝对路径,HTML 里的script标签统一改成相对当前文档的./renderer.js而不是/renderer.js。打包配置里把src、electron、node_modules显式列进files,不要依赖默认排除规则。排查这类问题最快的办法是打包后按Ctrl+Shift+I打开 DevTools 看 Network 面板,404 的路径会直接暴露问题所在。
5.2 中文输入法下光标乱跳、候选框错位
现象:用拼音输入法打字,候选框出现在屏幕左上角,选完词之后光标跳到段落开头,输入的内容顺序被打乱。
原因:CodeMirror 6 默认依赖浏览器的compositionstart/compositionend事件来标记输入法组词状态。如果updateListener在组合输入过程中就调了onChange,预览区会拿到一堆拼音中间态,同时某些自定义快捷键在组合态下误触发,导致光标定位失效。
解决:在updateListener里加组合态判断,组词期间不触发预览渲染:
let composing = false const editorDom = view.dom editorDom.addEventListener('compositionstart', () => { composing = true }) editorDom.addEventListener('compositionend', () => { composing = false renderPreview(view.state.doc.toString()) // 组词结束补一次渲染 }) // updateListener 内部 if (update.docChanged && !composing) onChange(update.state.doc.toString())另外,自定义快捷键里凡是绑了字母键的(比如Ctrl+B加粗),要在 keymap 里返回false让组合态放行,否则输入法正在组词时按字母会触发命令。
5.3 打开几十万行的日志文件直接卡死
现象:用户拖进来一个 30MB 的.md,界面转圈十几秒后无响应,任务管理器显示渲染进程内存飙到 1.5G。
原因:三个地方同时爆掉。一是fs.readFile一次性把 30MB 读成字符串;二是 CodeMirror 把整个文档建进状态树;三是 markdown-it 全量渲染后innerHTML替换,浏览器要为一堆代码块重排。三者叠加,任何一项单独优化都不够。
解决:入口处先stat拦一道(就是第 4.1 节那段代码),超过阈值走“大文件只读模式”——关闭实时预览,只保留纯文本编辑,用户手动点“渲染预览”才执行一次,并且渲染前截断到前 5000 行加一句提示。这个方案不优雅,但比让用户看着假死屏强得多。血泪经验是:不要指望通过虚拟滚动彻底解决,预览区的 DOM 高度计算在超大文档上本身就是个坑。
5.4 导出 PDF 里中文全变方块
现象:屏幕上预览汉字正常,导出 PDF 后标题和正文全变成一个个空心方框,英文和数字正常。
原因:loadFile加载的临时 HTML 是个独立文档,它没有继承主窗口的字体样式。系统默认的打印字体在部分 Windows 环境下不含中文字形,Chromium 打印时就回退到豆腐块。另一层原因是导出用的 CSS 里写的是font-family: -apple-system, sans-serif,在 Windows 上-apple-system无效,sans-serif又落到了不含中文的默认字体。
解决:导出模板里显式声明带中文回退的字体栈,并且把字体文件随包一起发,用@font-face引绝对路径:
/* 导出专用样式,不跟界面样式混用 */ @font-face { font-family: 'DocSans'; src: url('file:///path/to/assets/NotoSansSC-Regular.woff2') format('woff2'); } body { font-family: 'DocSans', 'PingFang SC', 'Microsoft YaHei', sans-serif; }字体文件路径要在主进程里按__dirname动态替换成绝对路径再写进 HTML,写死相对路径在打包后必然失效。
5.5 三个平台的快捷键各打各的
现象:Windows 上Ctrl+S保存正常,macOS 上按下没反应;菜单里显示的快捷键提示在 Linux 上是Ctrl,在 macOS 上还是Ctrl而不是Command。
原因:快捷键在渲染层用keydown手写监听时,event.ctrlKey和event.metaKey是两套体系。菜单加速键(accelerator)如果写死'CtrlOrCmd+S'之外的字符串,Electron 在非对应平台会直接忽略。
解决:统一走 Electron 的 accelerator 语法,把CtrlOrCmd作为修饰键别名交给框架处理,菜单里同时配置role:
const template = [ { label: '文件', submenu: [ { label: '新建', accelerator: 'CtrlOrCmd+N', click: () => sendMenu('new') }, { label: '保存', accelerator: 'CtrlOrCmd+S', click: () => sendMenu('save') }, { type: 'separator' }, { role: 'quit', label: '退出' } ] } ]渲染层不再自己判断ctrlKey || metaKey,所有快捷键从主进程菜单事件下发。这样快捷键提示文案、实际响应、平台差异三件事由 Electron 统一维护,改一处三平台同步生效。翻车点在于有人图快在渲染层写死e.ctrlKey,macOS 用户按 Command 完全没反应,反馈上来像是个“只在苹果电脑上出现的 bug”。
6. 二次开发:主题、插件和快捷键的改造入口
6.1 换主题改 CSS 变量,不要覆盖选择器
DBeditor 的样式层用了两层结构:一层是 CodeMirror 的主题扩展,一层是预览区的 CSS 变量。想换一套配色,正确做法是新建一个theme-xxx.js,在里面同时定义编辑器扩展和根节点上的变量表,而不是在现有样式后面追加覆盖规则。
// src/editor/theme/theme-solarized.js import { EditorView } from '@codemirror/view' export const solarizedVars = { '--bg': '#fdf6e3', '--fg': '#657b83', '--accent': '#268bd2', '--code-bg': '#eee8d5' } export function solarizedEditor() { return EditorView.theme({ '&': { backgroundColor: 'var(--bg)', color: 'var(--fg)' }, '.cm-activeLine': { backgroundColor: 'rgba(38,139,210,0.06)' } }) }切主题时调view.dispatch({ effects: themeCompartment.reconfigure(solarizedEditor()) }),同时把solarizedVars逐条style.setProperty挂到document.documentElement上。CodeMirror 的Compartment就是为这种动态重配设计的,不用销毁重建编辑器。覆盖式写法的问题是优先级越堆越高,改到第三套主题时!important满天飞,后面谁都不敢动。
6.2 加一条自定义 Markdown 语法
想加类似==高亮==这样的语法,入口在src/markdown/plugins.js,用 markdown-it 的 inline ruler 插一条规则。核心是匹配文本、生成mark_open/mark_close两个 token,再用 renderer 规则渲染成<mark>。注意要处理转义和代码块内的屏蔽,否则代码块里写==会被误替换,这是最常见的实现缺陷。
编辑器侧还要同步加一条 CodeMirror 的装饰规则,否则源码区看不出高亮语法,两边体验割裂。改完之后务必跑一篇包含行内代码、围栏代码、转义字符的测试文档,逐项比对预览结果——我吃过一次亏,正则写得贪心,把代码块里的a==b也包成了<mark>,用户反馈说“代码被染色了”。
6.3 改完怎么验证
三个检查点固定走一遍:一是拖入一个 1MB 以上的真实文档,看输入延迟和滚动是否还跟得上;二是三个平台各导一次 PDF,重点看中文和代码块底色;三是把渲染进程的 DevTools 打开,控制台必须干净,任何一条[Violation]长任务警告都值得追一下。
最后留一句我自己的习惯:从那以后我每次改parser.js,都会先把那个包含转义、行内代码、链接、表格、任务列表的测试文档拖进去跑一遍,再动手改代码。这类解析器的 bug 不会在简单示例里露头,只会等着在用户的正式文档里给你一记闷棍。希望帮到你。
本文还有配套的精品资源,点击获取