electron開發(fā)markdown編輯器

使用vue3 + pina + mavon-editor + electron開發(fā)的桌面應用
如果不想看下面的簡介,可以直接下載項目運行看效果
項目地址: markdown-electron-vue - 公開倉庫 (coding.net)
應用截圖:

image.png

運行環(huán)境:

node 20.14.0
npm 10.7.0

已完成功能列表

  • 集成應用程序菜單——左上角菜單
  • 菜單與頁面通信
  • 頁面與菜單通信
  • 菜單和快捷鍵-CommandOrControl+S保存文件到本地
  • 菜單和快捷鍵-CommandOrControl+O打開本地文件
  • 文件拖拽到編輯器內(nèi)打開,并可以保存到原來的文件中
  • renderer自己下載,應用圖標展示進度條
  • electron 打包,路由要使用hash模式,不然打包出來是白屏的

scripts

npm install

運行vue項目開發(fā)環(huán)境代碼

npm run dev

運行到electron開發(fā)環(huán)境

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引入菜單,并設(shè)置菜單
import { ..., Menu } from 'electron'
import { myMenu } from './menu.js'
...

Menu.setApplicationMenu(myMenu)

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

菜單與頁面通信思路

  1. 在src/preload.js全局注冊ipcRenderer,用ipcRenderer.on監(jiān)聽菜單出發(fā)的事件

src/preload.js核心代碼如下, 全局注冊ipcRenderer
這里send的validChannels定義的是頁面調(diào)用主進程的事件
receive的validChannelsd定義的是主進程調(diào)用頁面的事件

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中發(fā)出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監(jiān)聽的事件,調(diào)用頁面方法
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. 在頁面中調(diào)用通信的方法
function sendMessage () {
  window.ipcRenderer.send('set-title', `你好`)
}
  1. 在主進程src/background.js用ipcMain監(jiān)聽頁面發(fā)送的方法,調(diào)用系統(tǒng)方法
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回調(diào)方法中調(diào)用webContents.send('save')發(fā)送消息給頁面
  {
    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'事件,調(diào)用頁面定義的send事件ipcRenderer.send('save', {editor: editorValue.value, fileData})把數(shù)據(jù)傳給主進程
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主進程中監(jiān)聽頁面發(fā)送的save事件,然后做保存
    如果傳遞過來的數(shù)據(jù)有文件路徑,就保存到該路徑的文件內(nèi),否則調(diào)用選擇文件路徑的彈窗,保存為新文件
// 保存到本地
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數(shù)組里都要添加'save'

菜單和快捷鍵-CommandOrControl+O打開本地文件思路

  1. src/menu.js中添加File菜單,定義Open File子菜單
  2. accelerator定義快捷鍵
  3. click回調(diào)方法中,dialog.showOpenDialog選中文件后會返回文件的路徑,
    通過fs.readFileSync獲取到文件的內(nèi)容,調(diào)用主進程定義的'load'事件webContents.send('load', content)把內(nèi)容傳遞給頁面(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'事件的監(jiān)聽,把傳遞過來的參數(shù)賦值給編輯器
window.ipcRenderer && window.ipcRenderer.receive('load', (arg) => {
  // 賦值編輯器要顯示的內(nèi)容
  editorValue.value = arg
})

文件拖拽到編輯器內(nèi)打開

  1. app.vue根結(jié)點定義drop
<template>
  <div @drop.prevent="dropHandler" @dragover.prevent>
    ...
  </div>
</template>
  1. app.vue script定義dropHandler,dropHandler的參數(shù)中能拿到文件的路徑,F(xiàn)ileReader能讀取到打開的文件的內(nèi)容,分別都保存到store的openedFile、editorText狀態(tài)中
    文件的路徑是保存的時候會用到
function dropHandler (event) {
  event.preventDefault()
  if (event.dataTransfer.items) {
    if (event.dataTransfer.items[0].kind === 'file') {
      // file是一個File對象,直接傳給足進程會變成空對象,所以要轉(zhuǎn)換為一般的對象,用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中監(jiān)聽editorText,把傳遞過來的參數(shù)賦值給編輯器
watch(editorText, (val) => {
  if (val) {
    editorValue.value = editorText.value
  }
})

頁面下載文件,應用圖標展示進度條(僅在開發(fā)環(huán)境下看效果,打包后會報錯就未做處理了,這里就是給個思路)

  1. 頁面中定義好方法并調(diào)用,通過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. 主進程監(jiān)聽'download-progress'事件,調(diào)用系統(tǒng)事件
// 控制展示進度條
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文件,自行設(shè)置打包位置為builder文件夾,files根據(jù)自己的文件路徑按需要填進去

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

控制臺出現(xiàn)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上,頁面內(nèi)通過window.ipcRenderer發(fā)送方法

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容