為了方便記錄一些個人隨筆啼止,我最近用Laravel
和Vue 3.0
擼了一個博客系統(tǒng),其中使用到了一個基于 markdown-it 的 markdown
編輯器Vue組件v-md-editor。我感覺用它去編寫markdown
還是很方便的怯疤。后面就有了一個想法,基于此組件用Electron來實現(xiàn)一個markdown
桌面端應用,自己平時拿來使用也是個不錯的選擇脊髓。
題外話:VS Code就是用Electron開發(fā)出來的桌面應用,我現(xiàn)在除了移動端的開發(fā)外栅受,其他的都是使用VS Code來開發(fā)了将硝,各種插件開發(fā)起來真的很方便。
接下來我就帶大家來一步步來實現(xiàn)這個功能屏镊。
Vue CLI 搭建Vue項目
在選擇的目錄下執(zhí)行
vue create electron-vue3-mark-down
-
選擇自定義的模板(可以選擇默認的Vue 3 模板)
選擇Vue3 和 TypeScript, 其他的選項基于自身項目決定是否選擇
- 執(zhí)行
npm run serve
看看效果
Vue項目改造為markdown編輯器
執(zhí)行
npm i @kangc/v-md-editor@next -S
安裝v-md-editor添加TypeScript類型定義文件
由于v-md-editor這個庫沒有TypeScript類型定義文件依疼,我就直接在shims-vue.d.ts這個文件的后面添加的,當然也可以新建一個文件添加申明(tsconfig.json能找到這個文件就OK)而芥。
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
<!-- 添加的內(nèi)容 -->
declare 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";
- 改造App.vue
<template>
<div>
<v-md-editor v-model="content" height="100vh"></v-md-editor>
</div>
</template>
<script lang="ts">
// 編輯器
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";
// 高亮顯示
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 "@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>
/* 去掉一些按鈕 */
.v-md-icon-save,
.v-md-icon-fullscreen {
display: none;
}
</style>
這個文件也很簡單律罢,整個頁面就是一個編輯器
<v-md-editor v-model="content" height="100vh"></v-md-editor>
,這個markdown編輯器有高亮顯示棍丐,代碼顯示行號误辑,復制代碼按鈕等插件,當然更方便的是可以添加其他的插件豐富這個markdown編輯器的功能.
- 效果如下
Vue CLI Plugin Electron Builder
我嘗試過用Vite 2.0去搭建Electron項目歌逢,但是沒有找到類似的Vite和Electron結(jié)合好使的工具巾钉,所以放棄了Vite 2.0的誘惑。如果有小伙伴有推薦可以分享下秘案。
- 使用
vue add electron-builder
安裝砰苍,我選擇的是13.0.0的Electron的最新版本潦匈。
我一般是選擇最高的版本,其實這個版本有坑赚导,我后面再想想要不要介紹下這個坑历等,哈哈。
我們看到新加了很多的依賴庫辟癌,還添加了一個
background.ts
文件寒屯。簡單介紹下,這個文件執(zhí)行在主線程黍少,其他的頁面都是在渲染線程寡夹。渲染線程有很多限制的,有些功能只能在主線程執(zhí)行厂置,這里就不具體展開了菩掏。
- 執(zhí)行
npm run electron:serve
看效果
至此,就可以看到桌面應用的效果了昵济,并且邊修改Vue的代碼智绸,桌面應用也能實時看到修改后的效果。
優(yōu)化功能
啟動全屏顯示
- 引入screen
import { screen } from "electron";
- 創(chuàng)建窗口的時候設置為screen大小
<!-- background.ts -->
async function createWindow() {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
const win = new BrowserWindow({
width,
height,
// 省略...
});
// 省略...
}
這樣應用啟動的時候就是全屏顯示了访忿。
修改菜單欄
- 定義菜單欄
<!-- background.ts -->
const template: Array<MenuItemConstructorOptions> = [
{
label: "MarkDown",
submenu: [
{
label: "關(guān)于",
accelerator: "CmdOrCtrl+W",
role: "about",
},
{
label: "退出程序",
accelerator: "CmdOrCtrl+Q",
role: "quit",
},
],
},
{
label: "文件",
submenu: [
{
label: "打開文件",
accelerator: "CmdOrCtrl+O",
click: (
item: MenuItem,
focusedWindow: BrowserWindow | undefined,
_event: KeyboardEvent
) => {
// TODO: 打開文件
},
},
{
label: "存儲",
accelerator: "CmdOrCtrl+S",
click: (
item: MenuItem,
focusedWindow: BrowserWindow | undefined,
_event: KeyboardEvent
) => {
// TODO: 存儲內(nèi)容
},
},
],
},
{
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,
_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");
},
},
],
},
];
具體如何定義參閱Electron Menu瞧栗。
打開文件和存儲目前還沒實現(xiàn),后面實現(xiàn)海铆。
- 設置菜單欄
import { Menu } from "electron";
app.on("ready", async () => {
// 省略...
// 創(chuàng)建菜單
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
});
在
ready
鉤子函數(shù)中進行設置Menu迹恐。
- 效果
編輯器打開markdonw文件的內(nèi)容
- 主線程選擇文件,將文件路徑傳給渲染線程
<!-- 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];
// 將文件傳給渲染線程
if (focusedWindow) {
focusedWindow.webContents.send("open-file-path", filePath);
}
}
})
.catch((err) => {
console.log(err);
});
showOpenDialog
是打開文件的方法卧斟,我們這里指定了只打開md文件殴边;獲得文件路徑后,通過
focusedWindow.webContents.send("open-file-path", filePath);
這個方法將文件路徑傳給渲染線程珍语。
- 渲染線程取到文件路徑锤岸,讀取文件內(nèi)容,賦值給markdown編輯器
<!-- App.vue -->
import { ipcRenderer } from "electron";
import { readFileSync } from "fs";
export default defineComponent({
// 省略...
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 };
},
});
- vue添加node支持
<!-- vue.config.js -->
module.exports = {
pluginOptions: {
electronBuilder: {
nodeIntegration: true,
},
},
};
- 效果
markdonw的內(nèi)容存入文件
- 主線程發(fā)起向渲染線程獲取編輯器內(nèi)容的請求
<!-- background.js -->
if (focusedWindow) {
focusedWindow.webContents.send("get-content", "");
}
- 渲染線程主線程向返回編輯器的內(nèi)容
<!-- App.vue -->
onMounted(() => {
ipcRenderer.on("get-content", () => {
ipcRenderer.send("save-content", content.value);
});
});
- 主線程收到內(nèi)容然后存入文件
<!-- background.ts -->
// 存儲文件
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);
});
}
}
});
- 效果
打包
- 設置應用的名字和圖片
<!-- vue.config.js -->
module.exports = {
pluginOptions: {
electronBuilder: {
nodeIntegration: true,
// 添加的設置
builderOptions: {
appId: "com.johnny.markdown",
productName: "JJMarkDown", // 應用的名字
copyright: "Copyright ? 2021", //版權(quán)聲明
mac: {
icon: "./public/icon.icns", // icon
},
},
},
},
};
- icon.icns生成
- 準備一個1024*1024的圖片板乙,同級目錄下創(chuàng)建一個為
icons.iconset
的文件夾是偷; - 創(chuàng)建各種不同尺寸要求的圖片文件
sips -z 16 16 icon.png -o icons.iconset/icon_16x16.png
sips -z 32 32 icon.png -o icons.iconset/icon_16x16@2x.png
sips -z 32 32 icon.png -o icons.iconset/icon_32x32.png
sips -z 64 64 icon.png -o icons.iconset/icon_32x32@2x.png
sips -z 128 128 icon.png -o icons.iconset/icon_128x128.png
sips -z 256 256 icon.png -o icons.iconset/icon_128x128@2x.png
sips -z 256 256 icon.png -o icons.iconset/icon_256x256.png
sips -z 512 512 icon.png -o icons.iconset/icon_256x256@2x.png
sips -z 512 512 icon.png -o icons.iconset/icon_512x512.png
sips -z 1024 1024 icon.png -o icons.iconset/icon_512x512@2x.png
- 獲得名為icon.icns的圖標文件
iconutil -c icns icons.iconset -o icon.icns
- 打包
npm run electron:build
- 結(jié)果
獲得的dmg文件就可以直接安裝使用了。
代碼
<!-- 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: "關(guān)于",
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();
// 創(chuàng)建菜單
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();
});
}
}