目前有很多免费开源的 Markdown 编辑器,这些编辑器很多也都是使用 Electron 编写。

Electron 因为是使用 HTML + CSS + JavaScript 来开发桌面程序,所以有很多的开源 Markdown 解析库和编辑器可以用,CSS 编写样式也比较方便。

如果你希望快速完成,可以直接使用开源的前端编辑器,只需要编写少量的样式,其它就是文件操作和菜单。如果你想要自己编写样式,也可以使用 JS 的 Markdown 解析库把 Markdown 转换为 HTML,然后编写 CSS 样式。

我这里会直接使用开源的编辑器,使用的编辑器是 TOAST UI Editor

这篇文章编写的 Markdown 编辑器可以在 https://github.com/changbin1997/MNote 查看,在 Releases 中有打包完成的版本。

项目初始化

创建一个项目目录,进入项目目录,输入:

npm init -y

初始化项目。

安装 electron 和 electron-builder:

npm install electron electron-builder --save-dev

打开项目目录下的 package.json,在 scripts 中加入三条命令:

{
  "scripts": {
    "start": "electron .",
    "pack": "electron-builder --dir",
    "dist": "electron-builder"
  }
}

start 用来启动正在开发的 Electron 程序,pack 把程序打包为目录,dist 把程序打包为安装包。

可以在项目目录下创建一个 renderer 目录,把前端渲染进程的 HTML CSS JS 放到 renderer 目录。

完成基本的窗口

在项目根目录创建一个 index.js 文件,在 index.js 初始化窗口:

const {app, BrowserWindow} = require('electron');
const path = require('path');

let mainWindow  = null;

app.on('ready', async () => {
  // 创建窗口
  mainWindow = new BrowserWindow({
    width: 800,
    height: 500,
    webPreferences: {
      webSecurity: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  });

  // 加载html
  await mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));

  // 窗口关闭
  mainWindow.on('close', () => {
    mainWindow = null;
  });
});

上面通过 mainWindow.loadFile 加载了项目目录中 renderer 目录里的 index.html

现在的 Electron 已经无法在前端渲染进程直接使用 Node 和 Electron API,只能通过 preload 把要使用的 API 暴露给渲染进程,上面创建窗口时配置的 preloadpath.join(__dirname, 'preload.js') ,也就是入口文件目录下的 preload.js

下面在项目目录下创建一个 preload.js ,把 ipc 通信相关的 API 暴露给渲染进程:

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  onResponse: (channel, listener) => {
    ipcRenderer.on(channel, listener);
  },
  'ipc-invoke': (channel, listener) => {
    ipcRenderer.invoke(channel, listener);
  }
});

上面暴露了两个 API,·onResponse· 用来监听主进程发来的 ipc 请求,ipc-invoke 用来主动向主进程发送 ipc 请求。

renderer 目录主要用来存放前端渲染进程的文件,在 renderer 目录下创建一个 index.html 文件,引入 TOAST UI Editor 的 CSS 和 JS。

CSS:

<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" type="text/css">

通过 script 引入 JS:

<script type="text/javascript" src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script type="text/javascript" src="https://uicdn.toast.com/editor/latest/i18n/zh-cn.js"></script>

可以直接把 CSS 和 JS 文件下载到本地引入,不建议直接使用在线地址引入,其中的 zh-cn.js 是中文语言。

在 HTML 的 body 中放一个 div 作为编辑器容器:

<div id="editor"></div>

给程序加入基本的 CSS 样式:

html, body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  padding: 0;
  margin: 0;
}

#editor {
  width: 100vw;
  height: 100vh;
  box-sizing: border-box;
}

上面的样式可以让编辑器的尺寸和窗口的尺寸保持一致,窗口缩放编辑器也会缩放。

下面来初始化前端的编辑器,创建一个 JS 文件在 index.html 中引入,初始化编辑器:

const editorContainer = document.querySelector('#editor');

const Editor = toastui.Editor;
// 创建编辑器实例
const editor = new Editor({
  el: editorContainer,
  height: '100%',
  initialEditType: 'markdown',
  previewStyle: 'vertical',
  useCommandShortcut: false,
  language: 'zh-CN'
});

现在已经完成了基本的窗口和外观样式,可以在项目目录打开命令行,输入:

npm run start

如果代码没有错误的话,已经可以看到一个 Markdown 编辑器了。

Markdown编辑器外观和窗口部分

编写菜单栏

这里在菜单栏添加两个菜单,一个是文件操作,一个是编辑。

关于菜单栏的详细说明可以看我之前写的 Electron 编写菜单栏

为了方便后期维护,可以把菜单栏放到单独的文件,下面在项目目录的 app 目录创建一个 menu-bar.js ,在这个文件里编写菜单栏:

const { Menu } = require('electron');

module.exports = (mainWindow) => {
  // 菜单模板
  const menuTemplate = [
    {
      label: '文件',
      submenu: [
        {
          label: '打开',
          click() {
            // 打开文件点击
          },
          accelerator: 'ctrl+o'
        },
        {
          label: '保存',
          click() {
            // 保存文件点击
          },
          accelerator: 'ctrl+s'
        },
        {
          label: '另存为',
          click() {
            // 另存为点击
          },
          accelerator: 'ctrl+shift+s'
        },
        {
          label: '退出',
          click() {
            // 退出点击,直接退出
            mainWindow.close();
          }
        }
      ]
    },
    {
      label: '编辑',
      submenu: [
        {
          label: '全选',
          role: 'selectAll'
        },
        {
          label: '剪切',
          role: 'cut'
        },
        {
          label: '复制',
          role: 'copy'
        },
        {
          label: '粘贴',
          role: 'paste'
        }
      ]
    }
  ];

  const menuBar = Menu.buildFromTemplate(menuTemplate);
  Menu.setApplicationMenu(menuBar);
};

菜单项的 accelerator: 'ctrl+o' 之类的就是快捷键,可以直接通过快捷键来实现点击菜单,这也是大多数文件操作菜单的快捷键。

我的这个菜单栏函数还会接收一个 mainWindow ,点击退出就通过 mainWindow.close() 来退出。

这个 menu-bar.js 菜单模块需要在入口文件 index.js 引入,然后在窗口创建完成后调用。

下面在 index.js 引入调用:

const {app, BrowserWindow} = require('electron');
const path = require('path');
const menuBar = require('./app/menu-bar');

let mainWindow  = null;

app.on('ready', async () => {
  // 创建窗口
  mainWindow = new BrowserWindow({
    width: 800,
    height: 500,
    webPreferences: {
      webSecurity: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  });

  // 加载html
  await mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
  
  // 菜单栏初始化
  menuBar(mainWindow);

  // 窗口关闭
  mainWindow.on('close', () => {
    mainWindow = null;
  });
});

我上面的菜单栏模块需要接收一个 mainWindow 用于退出,这里调用 menuBar 初始化菜单的时候就传入了 mainWindow 主窗口。

现在可以输入:

npm run start

启动程序来查看菜单栏。

菜单栏实现效果如下:

编写本地Markdown编辑器-菜单栏

文件操作

可以在项目目录下的 app 目录中创建一个 File.js ,在这个 File.js 中创建一个 File 文件操作类,把文件操作相关的方法放到这个类中,方便管理和维护。

下面是 File 类,包含打开文件、保存文件、另存为文件:

const fs = require('fs');
const path = require('path');
const { dialog } = require('electron');

module.exports = class File {
  mainWindow = null; // 主窗口
  openFilePath = ''; // 当前打开的文件路径

  // 打开文件
  openFile() {
    // 显示打开文件对话框
    const filePath = dialog.showOpenDialogSync(this.mainWindow, {
      title: '打开 Markdown 文件',
      properties: ['openFile'],
      filters: [
        { name: 'Markdown 文件', extensions: ['md', 'markdown'] },
        { name: '所有文件', extensions: ['*'] }
      ]
    });
    // 未选择文件
    if (filePath === undefined || filePath.length < 1) return false;
    // 读取文件
    fs.readFile(filePath[0], 'utf-8', (error, content) => {
      if (error) {
        // 读取文件出错就显示一个提示对话框
        dialog.showMessageBoxSync(this.mainWindow, {
          title: '读取文件出错',
          message: `无法读取 ${filePath[0]}`,
          detail: error.message,
          buttons: ['关闭'],
          defaultId: 0,
          noLink: true
        });
        return false;
      }

      // 把文件传给渲染进程
      this.mainWindow.webContents.send('open-file', content);
      // 设置当前打开的文件名
      this.openFilePath = filePath[0];
    });
  }

  // 另存为文件
  saveAs(content) {
    // 显示保存文件对话框
    const fileName = dialog.showSaveDialogSync(this.mainWindow, {
      title: '另存为',
      buttonLabel: '保存',
      defaultPath: path.join(process.cwd(), 'markdown-file.md')
    });
    if (fileName === undefined) return false;

    fs.writeFile(fileName, content, 'utf-8', (error) => {
      if (error) {
        dialog.showMessageBoxSync(this.mainWindow, {
          title: '保存文件出错',
          message: `无法保存文件到 ${fileName}`,
          detail: error.message,
          buttons: ['关闭'],
          defaultId: 0,
          noLink: true
        });
        return false;
      }
      // 把打开的文件名设置为另存为的文件名
      this.openFilePath = fileName;
    });
  }

  // 保存文件
  save(content) {
    // 如果还没有打开过文件或文件不存在就直接调用另存为
    if (this.openFilePath === '' || !fs.existsSync(this.openFilePath)) {
      this.saveAs(content);
      return false;
    }
    // 把内容保存到当前打开的文件
    fs.writeFile(this.openFilePath, content, 'utf-8', (error) => {
      if (error) {
        dialog.showMessageBoxSync(this.mainWindow, {
          title: '保存文件出错',
          message: `无法保存文件到 ${this.openFilePath}`,
          detail: error.message,
          buttons: ['关闭'],
          defaultId: 0,
          noLink: true
        });
        return false;
      }
    });
  }
};

这个 File 类有两个属性,mainWindow 用来存储主窗口,发送内容到渲染进程需要用到 mainWindow,在主窗口内显示对话框也需要传入 mainWindowopenFilePath 用来存储打开的文件路径,保存文件的时候可以判断 openFilePath 是否有文件路径,如果有文件路径可以直接写入文件保存,如果没有文件路径可以弹出对话框另存为。

下面是详细的方法说明:

openFile 打开文件

这个方法用来打开文件,不需要接收参数。

  1. 通过 dialog.showOpenDialogSync 显示一个文件选择对话框,传入的 this.mainWindow 可以让对话框在主窗口内显示,filters 内的对象数组可以设置文件选择对话框内要显示的文件类型,我这里只显示 .md 后缀的 Markdown 文件。
  2. 如果取消了文件选择对话框就直接返回。
  3. 选择完文件就通过 fs.readFile 读取文件,filePath[0] 就是文件选择对话框返回的文件路径,utf-8 是直接通过 UTF-8 打开。
  4. 如果打开文件出错就显示一个错误提示,然后直接返回
  5. 成功读取文件就通过 mainWindow.webContents.send 把文件内容发送到渲染进程。
  6. 把文件路径传给 FileopenFilePath 属性,保存文件的时候可以直接通过这个路径保存。

我这里打开文件是直接通过 UTF-8 读取,如果文件不是 UTF-8,读取的文件可能是乱码。如果要读取一些不常用的编码,比如 GBK 之类的,打开文件的时候不需要传入第二个编码,直接把文件读取为 Buffer,然后通过一些相关的编码处理库来读取文件。

saveAs 另存为文件

这个方法用来另存为文件,需要接收文件内容,无论有没有打开文件,另存为都会弹出文件保存对话框。

  1. 通过 dialog.showSaveDialogSync 显示文件保存对话框,defaultPath 可以设置一个默认的路径和文件名,这里设置的默认文件路径 path.join(process.cwd(), 'markdown-file.md') 就是程序目录,默认的文件名是 markdown-file.md
  2. 如果文件保存对话框返回 undefined ,也就是取消了对话框,就直接返回。
  3. 通过 fs.writeFile 写入文件,第一个参数文件路径就使用文件保存对话框返回的路径,第二个参数 content 就是写入的内容,utf-8 就是通过 UTF-8 编码写入。
  4. 写入文件出错就显示一个对话框提示。
  5. 成功写入文件就把保存文件对话框返回的文件路径传给 FileopenFilePath 属性,保存文件可以直接使用这个路径保存。

Node 的 fs.writeFile 如果传入的文件路径不存在就会创建文件,如果文件存在就会直接写入。

save 保存文件

这个方法用来保存文件,需要接收 content 文件内容,保存文件不会弹出文件保存对话框,除非是没有打开文件的情况下。

  1. 判断 FileopenFilePath 是否包含文件路径,如果没有文件路径或文件不存在,说明是没有打开文件,直接调用 saveAs 方法来另存为文件,然后直接返回。
  2. 如果已打开文件就还是使用 fs.writeFile 写入文件,传入的文件路径就是 FileopenFilePath ,也就是当前打开的文件。
  3. 写入文件出错就显示一个对话框提示。

上面就是 File 文件操作类的三个主要的方法。

调整文件菜单

上面已经在 menu-bar.js 中编写了菜单栏,但是还没有给文件操作菜单加入功能,下面就来添加菜单功能。

上面的菜单函数只接收了一个 mainWindow,这里再添加一个 file ,这个 file 就是文件操作模块,菜单项可以直接调用文件操作模块。

下面在主进程 index.js 入口文件引入上面的 File.js 文件操作模块,然后在菜单初始化时把 File 传给菜单:

const {app, BrowserWindow} = require('electron');
const path = require('path');
const menuBar = require('./app/menu-bar');
const File = require('./app/File');

// 文件操作
const file = new File();

let mainWindow  = null;

app.on('ready', async () => {
  // 创建窗口...

  // 加载html
  await mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
  // 把主窗口传给文件操作模块,用于在窗口内显示对话框和 ipc 通信
  file.mainWindow = mainWindow;
  // 菜单栏初始化,把 file 传给菜单模块
  menuBar(mainWindow, file);

  // 窗口关闭
  mainWindow.on('close', () => {
    mainWindow = null;
  });
});

File 文件操作类有一个属性是 mainWindow ,这个属性用来存储主窗口,显示对话框和发送 ipc 会用到,index.js 入口创建完窗口后也需要把 mainWindow 传给 FilemainWindow 属性。

下面给菜单添加功能:

const { Menu } = require('electron');

module.exports = (mainWindow, file) => {
  // 菜单模板
  const menuTemplate = [
    {
      label: '文件',
      submenu: [
        {
          label: '打开',
          click() {
            // 调用 File 文件操作类的 open 方法
            file.openFile();
          },
          accelerator: 'ctrl+o'
        },
        {
          label: '保存',
          click() {
            // 通过 ipc 从前端渲染进程请求获取 markdown 内容
            mainWindow.webContents.send('get-markdown', 'save');
          },
          accelerator: 'ctrl+s'
        },
        {
          label: '另存为',
          click() {
            // 通过 ipc 从前端渲染进程请求获取 markdown 内容
            mainWindow.webContents.send('get-markdown', 'saveAs');
          },
          accelerator: 'ctrl+shift+s'
        },
        {
          label: '退出',
          click() {
            mainWindow.close();
          }
        }
      ]
    }
  ];

  const menuBar = Menu.buildFromTemplate(menuTemplate);
  Menu.setApplicationMenu(menuBar);
};

因为前端渲染进程和主进程的代码是分离的,只能通过 ipc 通信,主进程菜单点击保存和另存为时,需要通过 mainWindow.webContents.send 从渲染进程请求 Markdown 用于保存。

这里请求 Markdown 内容时发送的 savesaveAs 用于区分保存和另存为,渲染进程发送 Markdown 到主进程时,也会带上接收到的 savesaveAs

处理前端渲染进程的 ipc

主进程读取 Markdown 文件后需要发送到前端渲染进程显示,主进程保存文件也需要从前端渲染进程获取 Markdown 内容。

显示 Markdown 内容

上面的 File 类成功读取文件后会通过 mainWindow.webContents.send('open-file', content) 把 Markdown 内容传给渲染进程。

下面在前端渲染进程处理和显示 Markdown 内容:

// 监听主进程发来的 markdown 内容
window.electronAPI.onResponse('open-file', (ev, args) => {
  // 在编辑器显示 markdown
  editor.setMarkdown(args);
});

这里的 editor 就是上面使用 new Editor 初始化编辑器返回的编辑器对象。

保存 Markdown 内容

上面菜单选择保存或另存为时,会通过 mainWindow.webContents.send('get-markdown', 'save') 向渲染进程发送 ipc 请求来获取 Markdown 内容。

下面监听主进程发送的 ipc 请求,然后把 Markdown 内容通过 ipc 发送给主进程:

// 监听主进程发送的请求获取 Markdown 内容,用于保存文件
window.electronAPI.onResponse('get-markdown', (ev, args) => {
  // 获取编辑器中的 markdown 内容
  const markdownContent = editor.getMarkdown();
  // 把 Markdown 内容和接收到的执行功能一起发送到主进程
  window.electronAPI['ipc-invoke']('markdown-content', {
    content: markdownContent,
    exec: args
  });
});

前端在接受到请求后会通过 `editor.getMarkdown() 获取编辑器中的 Markdown 内容,然后通过 window.electronAPI['ipc-invoke']` 把 Markdown 内容和执行的功能发送到主进程。

前端渲染进程发送的是一个对象,content 就是 Markdown 内容,exec 是主进程菜单传过来的 savesaveAs 字符串,用来区分是保存还是另存为。

主进程菜单的保存和另存为点击后只会向渲染进程发送一个获取 Markdown 内容的请求,并不会立即保存,主进程还需要监听前端渲染进程发来的 Markdown 内容。

主进程接受到 Markdown 内容后就需要根据前端传过来的 savesaveAs 来选择 File 的保存或另存为。也就是说这个 savesaveAs 会从主进程菜单传到渲染进程,然后再从渲染进程传到主进程。

处理主进程的 ipc

下面在 index.js 入口文件处理主进程的 ipc:

// 监听前端传过来的 Markdown 内容,用于保存文件
ipcMain.handle('markdown-content', (ev, args) => {
  // 调用保存或另存为
  file[args.exec[0]](args.content);
});

File 文件操作类有 saveAssave 方法,用来另存为和保存文件。前端渲染进程船过来的 exec 就是用来区分选择保存或另存为,content 就是 Markdown 内容。

一些可选的功能

上面已经实现了一个简易的本地 Markdown 编辑器,包含读取文件和保存文件,还有基本的 Markdown 编辑和预览。

但是一个完整的 Markdown 编辑器肯定不只上面的功能,下面是一些可选的功能,因为内容较多,这里就不贴代码了,可以看我开发完成的编辑器 https://github.com/changbin1997/MNote

退出时询问和保存

在没有打开文件的情况下直接编写内容,如果内容没有保存,在退出的时候可以显示一个对话框询问是否保存,如果选择保存就弹出另存为,保存完成后退出。

如果打开的文件内容被修改,在退出时如果没有保存也可以弹出对话框询问保存,如果选择保存就把内容直接保存到当前打开的文件,然后退出。

打开文件时询问和保存

在没有打开文件的情况下选择打开文件,如果检测到编辑器里已经有内容就询问保存,选择保存后弹出另存为,保存完成后打开新文件。

打开新文件时,如果之前打开的文件被修改,但没有保存就弹框询问,选择保存就把内容直接保存到当前打开的文件,保存完成后打开新文件。

上下文菜单

Electron 的文本编辑区域不会和浏览器一样自带上下文菜单,虽然大多数人可能会使用键盘来实现 全选、剪切、复制、粘贴,但是还是会有人使用上下文菜单来完成这些操作。可以给 Markdown 编辑器区域加入一个上下文菜单,关于上下文菜单可以看 Electron 右键上下文菜单

关联文件启动

可以加入对 .md 文件的关联,在资源管理器打开 .md 文件就可以直接启动程序,然后读取文件显示。

关于关联文件可以看 Electron 在 Windows 关联文件启动

拖放打开文件

可以在前端的 Markdown 编辑器区域加入拖放事件,文件拖入后把文件路径发送到主进程读取文件显示。

打包

打开项目目录下的 package.json ,在 build 中配置打包信息:

{
  "build": {
    "appId": "markdown-edit",
    "productName": "Markdown编辑器",
    "icon": "renderer/favicon.ico",
    "copyright": "Copyright © 2025 changbin",
    "compression": "maximum",
    "asar": true,
    "win": {
      "icon": "assets/logo.ico",
      "target": "nsis",
      "legalTrademarks": "changbin"
    },
    "nsis": {
      "oneClick": false,
      "perMachine": true,
      "allowToChangeInstallationDirectory": true,
      "createDesktopShortcut": true,
      "createStartMenuShortcut": false
    },
    "directories": {
      "output": "release"
    },
    "files": [
      "index.js",
      "preload.js",
      "app/**/*",
      "assets/**/*",
      "renderer/**/*",
      "package.json",
      "!**/node_modules/*",
      "!node_modules/**/node_modules/*",
      "!package-lock.json",
      "!screenshots/**/*",
      "!**/.*",
      "!**/*.map",
      "!**/test?(s)/**/*",
      "!**/*.{md,txt,log,o,hprof,orig,pyc,pyo,rbc,pdb,ilk,bak}",
      "!**/._*",
      "!README.md",
      "!LICENSE"
    ]
  }
}

下面是一些配置说明:

  • appId: 程序ID
  • productName: 程序名称
  • icon: 程序图标
  • copyright: 版权信息
  • compression: 打包的压缩级别
  • win: Windows 相关的打包配置
  • win.icon: Windows 上的程序图标
  • win.legalTrademarks: 程序作者信息
  • nsis: 安装包相关的配置
  • nsis.oneClick: 是否使用一键安装
  • nsis.perMachine: 这台电脑的所有用户都能使用
  • nsis.allowToChangeInstallationDirectory: 允许更改安装目录
  • nsis.createDesktopShortcut: 创建桌面快捷方式
  • nsis.createStartMenuShortcut: 创建开始菜单快捷方式
  • directories.output: 打包后的软件存放位置
  • files: 配置需要打包的文件和需要过滤的文件

配置完成后在项目目录输入:

npm run dist

可以把程序打包为安装包。

输入:

npm run pack

可以把程序打包为一个目录,目录中有可以直接运行的 .exe 程序。

关联文件启动需要打包为安装包。

完整的 Markdown 编辑器可以看 https://github.com/changbin1997/MNote ,上面没有详细写的一些功能,在这个编辑器都有实现。