electron开发markdown编辑器

使用vue3 + pina + mavon-editor + electron开发的桌面应用
如果不想看下面的简介,可以直接下载项目运行看效果
项目地址: markdown-electron-vue - 公开仓库 (coding.net)
应用截图:

image.png

运行环境:

node 20.14.0
npm 10.7.0

已完成功能列表

  • 集成应用程序菜单——左上角菜单
  • 菜单与页面通信
  • 页面与菜单通信
  • 菜单和快捷键-CommandOrControl+S保存文件到本地
  • 菜单和快捷键-CommandOrControl+O打开本地文件
  • 文件拖拽到编辑器内打开,并可以保存到原来的文件中
  • renderer自己下载,应用图标展示进度条
  • electron 打包,路由要使用hash模式,不然打包出来是白屏的

scripts

npm install

运行vue项目开发环境代码

npm run dev

运行到electron开发环境

npm run start

electron打包

npm run electron_win
  • 打包mac平台运行 npm run electron_mac
  • 打包windows平台运行 npm run electron_win
  • 打包Linux平台运行 npm run electron_lin

Lint with ESLint

npm run lint

初始化

vite 新建一个项目
引入 mavon-editor, npm install --save mavon-editor
引入electron,npm install --save-dev electron
src目录下新建background.js/preload.js

  • package.json增加:
"scripts": {
  ...
  "start": "DEBUG=true electron ."
},
"main": "src/background.js"
  • __dirname编译报错处理
import { dirname } from "node:path"
import { fileURLToPath } from "node:url"

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

集成应用程序菜单——左上角菜单

  1. src目录下新增menu.js文件
import { Menu, MenuItem, shell } from 'electron'

const menu = new Menu()

// mac 左上角菜单
import { app, Menu, shell } from 'electron'
const isMac = process.platform === 'darwin'

const template = [
  // { role: 'appMenu' }
  ...(isMac
    ? [{
        label: app.name,
        submenu: [
          { role: 'about' },
          { type: 'separator' },
          { role: 'services' },
          { type: 'separator' },
          { role: 'hide' },
          { role: 'hideOthers' },
          { role: 'unhide' },
          { type: 'separator' },
          { role: 'quit' }
        ]
      }]
    : []),
  // { role: 'fileMenu' }
  {
    label: 'File',
    submenu: [
      isMac ? { role: 'close' } : { role: 'quit' }
    ]
  },
  // { role: 'editMenu' }
  {
    label: 'Edit',
    submenu: [
      { role: 'undo' },
      { role: 'redo' },
      { type: 'separator' },
      { role: 'cut' },
      { role: 'copy' },
      { role: 'paste' },
      ...(isMac
        ? [
            { role: 'pasteAndMatchStyle' },
            { role: 'delete' },
            { role: 'selectAll' },
            { type: 'separator' },
            {
              label: 'Speech',
              submenu: [
                { role: 'startSpeaking' },
                { role: 'stopSpeaking' }
              ]
            }
          ]
        : [
            { role: 'delete' },
            { type: 'separator' },
            { role: 'selectAll' }
          ])
    ]
  },
  // { role: 'viewMenu' }
  {
    label: 'View',
    submenu: [
      { role: 'reload' },
      { role: 'forceReload' },
      { role: 'toggleDevTools' },
      { type: 'separator' },
      { role: 'resetZoom' },
      { role: 'zoomIn' },
      { role: 'zoomOut' },
      { type: 'separator' },
      { role: 'togglefullscreen' }
    ]
  },
  // { role: 'windowMenu' }
  {
    label: 'Window',
    submenu: [
      { role: 'minimize' },
      { role: 'zoom' },
      ...(isMac
        ? [
            { type: 'separator' },
            { role: 'front' },
            { type: 'separator' },
            { role: 'window' }
          ]
        : [
            { role: 'close' }
          ])
    ]
  },
  {
    role: 'help',
    submenu: [
      {
        label: 'Learn More',
        accelerator: process.platform === 'darwin' ? 'Alt+Cmd+I' : 'Alt+Shift+I',
        click: async () => {
          // const { shell } = require('electron')
          await shell.openExternal('https://github.com/hinesboy/mavonEditor')
        }
      }
    ]
  }
]

const menu = Menu.buildFromTemplate(template)
export const myMenu = menu
  1. src/background.js引入菜单,并设置菜单
import { ..., Menu } from 'electron'
import { myMenu } from './menu.js'
...

Menu.setApplicationMenu(myMenu)

app.whenReady().then(() => {
  ...
})

菜单与页面通信思路

  1. 在src/preload.js全局注册ipcRenderer,用ipcRenderer.on监听菜单出发的事件

src/preload.js核心代码如下, 全局注册ipcRenderer
这里send的validChannels定义的是页面调用主进程的事件
receive的validChannelsd定义的是主进程调用页面的事件

contextBridge.exposeInMainWorld('ipcRenderer', {
  send: (channel, data) => {
    // console.log(channel, data)
    // Array of all ipcRenderer Channels used in the client
    let validChannels = ['set-title', 'save', 'download-progress']
    if (validChannels.includes(channel)) {
      ipcRenderer.send(channel, data)
    }
  },
  receive: (channel, func) => {
    // Array of all ipcMain Channels used in the electron
    let validChannels = ['insert-text', 'save', 'load']
    if (validChannels.includes(channel)) {
      // Deliberately strip event as it includes `sender`
      ipcRenderer.on(channel, (event, ...args) => func(...args))
    }
  }
})

src/menu.js中发出insert-text事件给Renderer

...
  {
    label: 'Format',
    submenu: [
      {
        label: 'Test Communication',
        click: () => {
          const window = BrowserWindow.getFocusedWindow()
          window.webContents.send('insert-text', 'test communication with renderer process')
        }
      }
    ]
  }
  1. demo.vue页面收到ipcRenderer.on监听的事件,调用页面方法
window.ipcRenderer && window.ipcRenderer.receive('insert-text', (arg) => {
  document.getElementById('communicationText').innerHTML = arg
  setTimeout(() => {
    document.getElementById('communicationText').innerHTML = ''
  }, 5000)
})

页面与主进程通信思路

  1. 在src/preload.js全局注册ipcRenderer,上面菜单与页面通信思路中第1点有介绍
  2. 在页面中调用通信的方法
function sendMessage () {
  window.ipcRenderer.send('set-title', `你好`)
}
  1. 在主进程src/background.js用ipcMain监听页面发送的方法,调用系统方法
ipcMain.on('set-title', (event, title) => {
  // console.log(`received ${title}`)
  const webContents = event.sender
  const win = BrowserWindow.fromWebContents(webContents)
  win.setTitle(title)
})

菜单和快捷键-CommandOrControl+S保存文件到本地思路

  1. src/menu.js中添加File菜单,定义Save File子菜单
  2. accelerator定义快捷键
  3. click回调方法中调用webContents.send('save')发送消息给页面
  {
    label: 'File',
    submenu: [
      ...
      {
        label: 'Save File',
        accelerator: platform === 'darwin' ? 'Cmd+S' : 'Ctr+S',
        click: () => {
          const window = BrowserWindow.getFocusedWindow()
          window.webContents.send('save')
        }
      }
    ]
  },
  1. HomeView.vue页面收到主进程定义的'save'事件,调用页面定义的send事件ipcRenderer.send('save', {editor: editorValue.value, fileData})把数据传给主进程
function saveHandler () {
  // console.log(openedFileData.value)
  const fileData = {
    path: openedFileData.value.path,
    name: openedFileData.value.name
  }
  window.ipcRenderer.send('save', {editor: editorValue.value, fileData})
}

window.ipcRenderer && window.ipcRenderer.receive('save', () => {
  saveHandler()
})
  1. src/background.js主进程中监听页面发送的save事件,然后做保存
    如果传递过来的数据有文件路径,就保存到该路径的文件内,否则调用选择文件路径的弹窗,保存为新文件
// 保存到本地
ipcMain.on('save', (event, arg) => {
  const fileDataPath = (arg.fileData && arg.fileData.path) || ''
  const window = BrowserWindow.getFocusedWindow()
  const options = {
    title: 'Save markdown file',
    filters: [{
      name: 'MyFile',
      extensions: ['md']
    }]
  }
  // 本地有该文件就直接保存
  if (fileDataPath) {
    fs.exists(fileDataPath, (res) => {
      if (res) {
        fs.writeFileSync(fileDataPath, arg.editor)
      } else {
        // 文件打开后可能会被删除,删除后就走保存的逻辑
        dialog.showSaveDialog(window, options).then(res => {
          const { filePath } = res
          if (filePath) {
            // console.log(`Saving content to the file: ${filePath}`)
            fs.writeFileSync(filePath, arg.editor)
          }
        })
      }
    })
    return false
  }
  
  dialog.showSaveDialog(window, options).then(res => {
    const { filePath } = res
    if (filePath) {
      // console.log(`Saving content to the file: ${filePath}`)
      fs.writeFileSync(filePath, arg.editor)
    }
  })
})
  1. 注意这里的保存操作,页面定义了'save'事件,主进程也定义了'save'事件,
    所以src/preload.js里两个validChannels数组里都要添加'save'

菜单和快捷键-CommandOrControl+O打开本地文件思路

  1. src/menu.js中添加File菜单,定义Open File子菜单
  2. accelerator定义快捷键
  3. click回调方法中,dialog.showOpenDialog选中文件后会返回文件的路径,
    通过fs.readFileSync获取到文件的内容,调用主进程定义的'load'事件webContents.send('load', content)把内容传递给页面(Renderer进程)
  {
    label: 'File',
    submenu: [
      {
        label: 'Open File',
        accelerator: platform === 'darwin' ? 'Cmd+O' : 'Ctr+O',
        click: () => {
          const window = BrowserWindow.getFocusedWindow()
          const options = {
            title: 'Pick a markdown file',
            filters: [{
              name: 'Markdown files',
              extensions: ['md']
            }, {
              name: 'Text files',
              extensions: ['txt']
            }]
          }
          dialog.showOpenDialog(window, options).then((res) => {
            const { filePaths } = res
            if (filePaths && filePaths.length > 0) {
              // console.log(`Saving content to the file: ${filePath}`)
              const content = fs.readFileSync(filePaths[0]).toString()
              window.webContents.send('load', content)
            }
          })
        }
      },
      ...
    ]
  },
  1. HomeView.vue中进行'load'事件的监听,把传递过来的参数赋值给编辑器
window.ipcRenderer && window.ipcRenderer.receive('load', (arg) => {
  // 赋值编辑器要显示的内容
  editorValue.value = arg
})

文件拖拽到编辑器内打开

  1. app.vue根结点定义drop
<template>
  <div @drop.prevent="dropHandler" @dragover.prevent>
    ...
  </div>
</template>
  1. app.vue script定义dropHandler,dropHandler的参数中能拿到文件的路径,FileReader能读取到打开的文件的内容,分别都保存到store的openedFile、editorText状态中
    文件的路径是保存的时候会用到
function dropHandler (event) {
  event.preventDefault()
  if (event.dataTransfer.items) {
    if (event.dataTransfer.items[0].kind === 'file') {
      // file是一个File对象,直接传给足进程会变成空对象,所以要转换为一般的对象,用fileData存一下
      const file = event.dataTransfer.items[0].getAsFile()
      const fileData = {
        path: file.path,
        name: file.name,
        type: file.type,
        size: file.size,
        webkitRelativePath: file.webkitRelativePath,
        lastModified: file.lastModified
      }
      store.changeOpenedFile(fileData)
      // console.log(file)
      if (file.type === 'text/markdown') {
        var reader = new FileReader()
        reader.onload = e => {
          const content = e.target.result
          store.changeEditorText(content)
        }
        reader.readAsText(file)
      }
    }
  }
}
  1. HomeView.vue中监听editorText,把传递过来的参数赋值给编辑器
watch(editorText, (val) => {
  if (val) {
    editorValue.value = editorText.value
  }
})

页面下载文件,应用图标展示进度条(仅在开发环境下看效果,打包后会报错就未做处理了,这里就是给个思路)

  1. 页面中定义好方法并调用,通过axios的onDownloadProgress把进度值传给主进程window.ipcRenderer.send('download-progress', progressEvent.progress)
function downloadFile () {
  axios({
    method: 'GET',
    // url: '/static/fiddle.zip',
    url: '/static/template.xlsx',
    responseType: 'blob',
    onDownloadProgress: function (progressEvent) {
      // 对原生进度事件的处理
      window.ipcRenderer.send('download-progress', progressEvent.progress)
    },
  }).then(({data}) => {
    var fileName = 'template.xlsx'
    // var fileName = 'fiddle.zip'
    fileSaver.saveAs(data, fileName)

    setTimeout(() => {
      // 让进度条消失
      window.ipcRenderer.send('download-progress', -1)
    }, 5000)
  })
}
  1. 主进程监听'download-progress'事件,调用系统事件
// 控制展示进度条
ipcMain.on('download-progress', (event, arg) => {
  mainWindow.setProgressBar(arg)
})

打包

引入electron-builder,npm install --save-dev electron-builder

package.json 的 scripts 里增加:

  ...
  "scripts": {
    ...
    "electron_lin": "vite build && electron-builder -l",
    "electron_win": "vite build && electron-builder -w",
    "electron_mac": "vite build && electron-builder -m"
  }
  ...

默认打包是打包到根目录dist文件夹下,在根目录新增electron-builder.json文件,自行设置打包位置为builder文件夹,files根据自己的文件路径按需要填进去

{
  "files": ["./vue-dist", "./src/background.js", "./src/menu.js", "./src/preload.js", "package.json"],
  "directories": {
    "output": "builder" // 设置出口文件
  }
}
  • 打包mac平台运行 npm run electron_mac
  • 打包windows平台运行 npm run electron_win
  • 打包Linux平台运行 npm run electron_lin

控制台出现downloading后,拿其后面的地址去下载,下载好后放到下面的地址中就可以打包了

各平台目录地址

Linux: $XDG_CACHE_HOME or ~/.cache/electron/
MacOS: ~/Library/Caches/electron/
Windows: %LOCALAPPDATA%/electron/Cache or ~/AppData/Local/electron/Cache/

wine-4.0.1-mac.7z该文件需要解压放到下面目录

MacOS: ~/Library/Caches/electron-builder/wine/
Linux: ~/.cache/electron-builder/wine/
windows: %LOCALAPPDATA%\electron-builder\cache\wine

nsis-resources-3.4.1.7z该文件需要解压放到下面目录,形如 /electron-builder/nsis/nsis-resources-3.4.1/

MacOS: ~/Library/Caches/electron-builder/nsis/
Linux: ~/.cache/electron-builder/nsis/
windows: %LOCALAPPDATA%\electron-builder\cache\nsis\

遇到的问题

vue3 页面引入ipcRenderer.send会报错
Uncaught ReferenceError: __dirname is not defined
at node_modules/electron/index.js (electron.js?v=b0246f31:36:30)
at __require (chunk-DZZM6G22.js?v=b0246f31:9:50)
at electron.js?v=b0246f31:54:16

解决方案: 不要在.vue页面直接引入ipcRenderer,通过在preload.js中暴露ipcRenderer到window上,页面内通过window.ipcRenderer发送方法

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容