In order to facilitate writing some personal essays, I recently built a blog system using Laravel and Vue 3.0, which uses a Markdown editor Vue component based on Markdown. I found it quite handy to write Markdown. Later, I had an idea that based on this component, I could use Electron to realize a Markdown desktop application, which is also a good choice to use at ordinary times.

Side note: VS Code is a desktop application developed by Electron. Now I use VS Code for all my development except mobile. It’s really convenient to develop various plug-ins.

I’m going to walk you through the implementation of this feature step by step.

Vue CLI Setup Vue project

  • Run vue create electron-vue3-mark-down in the selected directory

  • Select a custom template (you can choose the default Vue 3 template)

  • Choose Vue3 and TypeScript, and the other options depend on your project

  • performnpm run serveSee the effect

Vue project was transformed intomarkdownThe editor

  • Run NPM i@kangc /v-md-editor@next -s to install v-MD-Editor

  • Add the TypeScript type definition file

Since the v-md-Editor library doesn’t have TypeScript type definitions, I added them directly to the shims-vue.d.ts file, or I could create a new file to add declarations (tsconfig.json is OK if it can be found).

declare module "*.vue" { import type { DefineComponent } from "vue"; const component: DefineComponent<{}, {}, any>; export default component; } <! - add content - > declare the module "@ kangc/v - md - editor/lib/theme/vuepress js"; declare module "@kangc/v-md-editor/lib/plugins/copy-code/index"; declare module "@kangc/v-md-editor/lib/plugins/line-number/index"; declare module "@kangc/v-md-editor"; declare module "prismjs";Copy the code
  • Transform the App. Vue
<template> <div> <v-md-editor v-model="content" height="100vh"></v-md-editor> </div> </template> <script lang="ts"> // Editor import VMdEditor from "@kangc/ V-md-editor "; import "@kangc/v-md-editor/lib/style/base-editor.css"; import vuepress from "@kangc/v-md-editor/lib/theme/vuepress.js"; import "@kangc/v-md-editor/lib/theme/style/vuepress.css"; // Highlight import Prism from "prismjs"; import "prismjs/components/prism-json"; import "prismjs/components/prism-dart"; import "prismjs/components/prism-c"; import "prismjs/components/prism-swift"; import "prismjs/components/prism-kotlin"; import "prismjs/components/prism-java"; Import createCopyCodePlugin from "@kangc/v-md-editor/lib/plugins/copy-code/index"; import createCopyCodePlugin from "@kangc/v-md-editor/lib/plugins/copy-code/index"; import "@kangc/v-md-editor/lib/plugins/copy-code/copy-code.css"; Import createLineNumbertPlugin from "@kangc/v-md-editor/lib/plugins/line-number/index"; VMdEditor.use(vuepress, { Prism, }) .use(createCopyCodePlugin()) .use(createLineNumbertPlugin()); import { defineComponent, ref } from "vue"; export default defineComponent({ name: "App", components: { VMdEditor }, setup() { const content = ref(""); return { content }; }}); </script> <style> /* Remove some buttons */. V-md-icon-save,. V-md-icon-fullscreen {display: none; } </style>Copy the code

< V-md-Editor v-model=”content” height=”100vh”>

More conveniently, other plug-ins can be added to enrich the Markdown editor.

  • Results the following

Vue CLI Plugin Electron Builder

I tried to build the Electron project with Vite 2.0, but failed to find a similar tool that could combine Vite and Electron well, so I gave up the temptation of Vite 2.0. If there is a small partner can share the recommendation.

  • usevue add electron-builderInstall, and I said yes13.0.0theElectronThe latest version of the.

I usually choose the highest version, actually this version has a pit, I will later think about whether to introduce this pit, haha.

We see a lot of new dependency libraries and a background.ts file. To recap, this file is executed in the main thread, and the rest of the page is executed in the render thread. The render thread has a lot of limitations, and some functions can only be performed in the main thread, so I won’t go into details here.

  • performnpm run electron:serveSee the effect

At this point, you can see the effect of the desktop application, and while modifying the Vue code, the desktop application can see the effect of the modification in real time.

Optimization function

Enable full screen display
  • The introduction of screen
import { screen } from "electron";
Copy the code
  • Set the window size to Screen when creating it
<! -- background.ts --> async function createWindow() { const { width, height } = screen.getPrimaryDisplay().workAreaSize; Const win = new BrowserWindow({width, height, // omit... }); / / to omit... }Copy the code

So the app starts up in full screen.

Modify menu bar
  • Defining the menu bar
<! -- background.ts --> const template: Array<MenuItemConstructorOptions> = [ { label: "MarkDown", submenu: [ { label: "About ", Accelerator: "CmdOrCtrl+W", role: "about",}, {label:" exit the program ", Accelerator: "CmdOrCtrl+Q", role: "Quit ",},],}, {label:" file ", submenu: [{label: "open file ", Accelerator: "CmdOrCtrl+O", click: (item: MenuItem, focusedWindow: BrowserWindow | undefined, _event: KeyboardEvent) = >} {/ / TODO: open the file,}, {label: "Storage" accelerator, "CmdOrCtrl + S", click: (item: MenuItem, focusedWindow: BrowserWindow | undefined, _event: KeyboardEvent) => {// TODO: Store content},},],}, {label: "edit ", submenu: [{label:" undo ", Accelerator: "CmdOrCtrl+Z", role: "undo",}, {label: "redo", Accelerator: "Shift+CmdOrCtrl+Z", role: "redo",}, {type: "Separator ",}, {label: "cut", Accelerator: "CmdOrCtrl+X", role: "cut",}, {label:" copy ", Accelerator: "Role: CmdOrCtrl + C", "copy",}, {label: "paste", accelerator: "CmdOrCtrl + V," role: "paste",},],}, {label: "window", the role: "Window ", submenu: [{label: "minimize", accelerator: "CmdOrCtrl+M", role: "minimize",}, {label:" maximize ", accelerator: "CmdOrCtrl+M", click: ( item: MenuItem, focusedWindow: BrowserWindow | undefined, _event: KeyboardEvent ) => { if (focusedWindow) { focusedWindow.maximize(); } }, }, { type: "separator", }, { label: "Switch to full screen ", Accelerator: (function () { if (process.platform === "darwin") { return "Ctrl+Command+F"; } else { return "F11"; } })(), click: ( item: MenuItem, focusedWindow: BrowserWindow | undefined, // eslint-disable-next-line @typescript-eslint/no-unused-vars _event: KeyboardEvent ) => { if (focusedWindow) { focusedWindow.setFullScreen(!focusedWindow.isFullScreen()); } }, }, ], }, {label: "help", role: "help", submenu: [{label: "learn more ", click: function () { shell.openExternal("http://electron.atom.io"); }, }, ], }, ];Copy the code
  1. See Electron Menu for details.

  2. Open file and store is currently not implemented, implemented later.

  • Set menu bar
import { Menu } from "electron"; App.on ("ready", async () => {// omit... // create Menu menu.setApplicationMenu (menu.buildFromTemplate (template)); });Copy the code

Set Menu in the Ready hook function.

  • The effect

The editor opens the contents of the Markdonw file
  • The main thread selects the file and passes the file path to the render thread
<! -- background.ts --> dialog .showOpenDialog({ properties: ["openFile"], filters: [{ name: "Custom File Type", extensions: ["md"] }], }) .then((res) => { if (res && res["filePaths"].length > 0) { const filePath = res["filePaths"][0]; / / file to render the if threads (focusedWindow) {focusedWindow. WebContents. Send (" open - file - path ", filePath); } } }) .catch((err) => { console.log(err); });Copy the code
  1. ShowOpenDialog is the method to open the file, we specify only open the MD file;

  2. After get file path, through focusedWindow. WebContents. Send (” open – file – path “, filePath); This method passes the file path to the renderer thread.

  • The rendering thread takes the file path, reads the file contents, and assigns values to the Markdown editor
<! -- App.vue --> import { ipcRenderer } from "electron"; import { readFileSync } from "fs"; Export default defineComponent({// omit... setup() { const content = ref(""); onMounted(() => { // 1. ipcRenderer.on("open-file-path", (e, filePath: string) => { if (filePath && filePath.length > 0) { // 2. content.value = readFileSync(filePath).toString(); }}); }); return { content }; }});Copy the code
  • Vue adds node support
<! -- vue.config.js --> module.exports = { pluginOptions: { electronBuilder: { nodeIntegration: true, }, }, };Copy the code
  • The effect

The contents of Markdonw are stored in a file
  • The main thread makes a request to the renderer thread for the contents of the editor
<! -- background.js --> if (focusedWindow) { focusedWindow.webContents.send("get-content", ""); }Copy the code
  • The main thread of the render thread returns the contents of the editor
<! -- App.vue --> onMounted(() => { ipcRenderer.on("get-content", () => { ipcRenderer.send("save-content", content.value); }); });Copy the code
  • The main thread receives the content and stores it to a file
<! Ipcmain. on("save-content", (event: unknown, content: String) => {if (openedfile.length > 0) {try {writeFileSync(openedFile, content); Console. log(" Saved successfully "); } catch (error) {console.log(" save failed "); }} else {const options = {title: "Save File ", defaultPath: "new.md", filters: [{name: "Custom File Type", Extensions: ["md"] }], }; const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { dialog .showSaveDialog(focusedWindow, options) .then((result: Electron.SaveDialogReturnValue) => { if (result.filePath) { try { writeFileSync(result.filePath, content); Console. log(" Saved successfully "); openedFile = result.filePath; } catch (error) {console.log(" save failed "); } } }) .catch((error) => { console.log(error); }); }}});Copy the code
  • The effect

packaging

  • Set the name and image of the app
<! -- vue.config.js --> module.exports = { pluginOptions: { electronBuilder: { nodeIntegration: BuilderOptions: {appId: "com.johnny. Markdown ", productName: "JJMarkDown", // The name of the application is copyright: "Copyright © 2021", / / Copyright notice MAC: {icon: ". / public/icon. Icns ", / / icon},},},,}};Copy the code
  • Icon. Icns generated
  1. Create an image of 1024 x 1024 in the same directory asicons.iconsetFolder;
  2. Create image files of different sizes
sips -z 16 16 icon.png -o icons.iconset/icon_16x16.png sips -z 32 32 icon.png -o icons.iconset/[email protected] sips -z  32 32 icon.png -o icons.iconset/icon_32x32.png sips -z 64 64 icon.png -o icons.iconset/[email protected] sips -z 128 128 icon.png -o icons.iconset/icon_128x128.png sips -z 256 256 icon.png -o icons.iconset/[email protected] sips -z 256  256 icon.png -o icons.iconset/icon_256x256.png sips -z 512 512 icon.png -o icons.iconset/[email protected] sips -z 512 512 icon.png -o icons.iconset/icon_512x512.png sips -z 1024 1024 icon.png -o icons.iconset/[email protected]Copy the code
  1. Get an icon file named icon.icns
iconutil -c icns icons.iconset -o icon.icns
Copy the code
  • packaging
npm run electron:build
Copy the code
  • The results of

The OBTAINED DMG files can be installed and used directly.

code

<!-- background.ts -->
"use strict";

import {
  app,
  protocol,
  BrowserWindow,
  screen,
  Menu,
  MenuItem,
  shell,
  dialog,
  ipcMain,
} from "electron";
import { KeyboardEvent, MenuItemConstructorOptions } from "electron/main";
import { createProtocol } from "vue-cli-plugin-electron-builder/lib";
import installExtension, { VUEJS3_DEVTOOLS } from "electron-devtools-installer";
const isDevelopment = process.env.NODE_ENV !== "production";
import { writeFileSync } from "fs";

let openedFile = "";
// 存储文件
ipcMain.on("save-content", (event: unknown, content: string) => {
  if (openedFile.length > 0) {
    // 直接存储到文件中去
    try {
      writeFileSync(openedFile, content);
      console.log("保存成功");
    } catch (error) {
      console.log("保存失败");
    }
  } else {
    const options = {
      title: "保存文件",
      defaultPath: "new.md",
      filters: [{ name: "Custom File Type", extensions: ["md"] }],
    };
    const focusedWindow = BrowserWindow.getFocusedWindow();
    if (focusedWindow) {
      dialog
        .showSaveDialog(focusedWindow, options)
        .then((result: Electron.SaveDialogReturnValue) => {
          if (result.filePath) {
            try {
              writeFileSync(result.filePath, content);
              console.log("保存成功");
              openedFile = result.filePath;
            } catch (error) {
              console.log("保存失败");
            }
          }
        })
        .catch((error) => {
          console.log(error);
        });
    }
  }
});

const template: Array<MenuItemConstructorOptions> = [
  {
    label: "MarkDown",
    submenu: [
      {
        label: "关于",
        accelerator: "CmdOrCtrl+W",
        role: "about",
      },
      {
        label: "退出程序",
        accelerator: "CmdOrCtrl+Q",
        role: "quit",
      },
    ],
  },
  {
    label: "文件",
    submenu: [
      {
        label: "打开文件",
        accelerator: "CmdOrCtrl+O",
        click: (
          item: MenuItem,
          focusedWindow: BrowserWindow | undefined,
          // eslint-disable-next-line @typescript-eslint/no-unused-vars
          _event: KeyboardEvent
        ) => {
          dialog
            .showOpenDialog({
              properties: ["openFile"],
              filters: [{ name: "Custom File Type", extensions: ["md"] }],
            })
            .then((res) => {
              if (res && res["filePaths"].length > 0) {
                const filePath = res["filePaths"][0];
                // 将文件传给渲染线程
                if (focusedWindow) {
                  focusedWindow.webContents.send("open-file-path", filePath);
                  openedFile = filePath;
                }
              }
            })
            .catch((err) => {
              console.log(err);
            });
        },
      },
      {
        label: "存储",
        accelerator: "CmdOrCtrl+S",
        click: (
          item: MenuItem,
          focusedWindow: BrowserWindow | undefined,
          // eslint-disable-next-line @typescript-eslint/no-unused-vars
          _event: KeyboardEvent
        ) => {
          if (focusedWindow) {
            focusedWindow.webContents.send("get-content", "");
          }
        },
      },
    ],
  },
  {
    label: "编辑",
    submenu: [
      {
        label: "撤销",
        accelerator: "CmdOrCtrl+Z",
        role: "undo",
      },
      {
        label: "重做",
        accelerator: "Shift+CmdOrCtrl+Z",
        role: "redo",
      },
      {
        type: "separator",
      },
      {
        label: "剪切",
        accelerator: "CmdOrCtrl+X",
        role: "cut",
      },
      {
        label: "复制",
        accelerator: "CmdOrCtrl+C",
        role: "copy",
      },
      {
        label: "粘贴",
        accelerator: "CmdOrCtrl+V",
        role: "paste",
      },
    ],
  },
  {
    label: "窗口",
    role: "window",
    submenu: [
      {
        label: "最小化",
        accelerator: "CmdOrCtrl+M",
        role: "minimize",
      },
      {
        label: "最大化",
        accelerator: "CmdOrCtrl+M",
        click: (
          item: MenuItem,
          focusedWindow: BrowserWindow | undefined,
          // eslint-disable-next-line @typescript-eslint/no-unused-vars
          _event: KeyboardEvent
        ) => {
          if (focusedWindow) {
            focusedWindow.maximize();
          }
        },
      },
      {
        type: "separator",
      },
      {
        label: "切换全屏",
        accelerator: (function () {
          if (process.platform === "darwin") {
            return "Ctrl+Command+F";
          } else {
            return "F11";
          }
        })(),
        click: (
          item: MenuItem,
          focusedWindow: BrowserWindow | undefined,
          // eslint-disable-next-line @typescript-eslint/no-unused-vars
          _event: KeyboardEvent
        ) => {
          if (focusedWindow) {
            focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
          }
        },
      },
    ],
  },
  {
    label: "帮助",
    role: "help",
    submenu: [
      {
        label: "学习更多",
        click: function () {
          shell.openExternal("http://electron.atom.io");
        },
      },
    ],
  },
];

protocol.registerSchemesAsPrivileged([
  { scheme: "app", privileges: { secure: true, standard: true } },
]);

async function createWindow() {
  const { width, height } = screen.getPrimaryDisplay().workAreaSize;
  const win = new BrowserWindow({
    width,
    height,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    },
  });

  if (process.env.WEBPACK_DEV_SERVER_URL) {
    // Load the url of the dev server if in development mode
    await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string);
    if (!process.env.IS_TEST) win.webContents.openDevTools();
  } else {
    createProtocol("app");
    // Load the index.html when not in development
    win.loadURL("app://./index.html");
  }
}

// Quit when all windows are closed.
app.on("window-all-closed", () => {
  // On macOS it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  if (process.platform !== "darwin") {
    app.quit();
  }
});

app.on("activate", () => {
  // On macOS it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", async () => {
  if (isDevelopment && !process.env.IS_TEST) {
    // Install Vue Devtools
    try {
      await installExtension(VUEJS3_DEVTOOLS);
    } catch (e) {
      console.error("Vue Devtools failed to install:", e.toString());
    }
  }
  createWindow();
  // 创建菜单
  Menu.setApplicationMenu(Menu.buildFromTemplate(template));
});

// Exit cleanly on request from parent process in development mode.
if (isDevelopment) {
  if (process.platform === "win32") {
    process.on("message", (data) => {
      if (data === "graceful-exit") {
        app.quit();
      }
    });
  } else {
    process.on("SIGTERM", () => {
      app.quit();
    });
  }
}
Copy the code