electron开发markdown编辑器

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

electron开发markdown编辑器

运行环境:

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-buildercachewine

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-buildercache
sis

遇到的问题

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发送方法

© 版权声明

相关文章

暂无评论

none
暂无评论...