...

使用Electron及React开发Markdown笔记软件

前言

本文通过Youtube视频:- Build a Markdown Notes app with Electron, React, Typescript, Tailwind and Jotai - YouTube
整理而来。

是基于Electron、React、TypeScript、Tailwind及Jotai来开发一款基础功能的Markdown笔记软件。

技术栈及依赖

IDE/编辑器

编辑器这里使用VSCode

VSCode推荐插件

  • Auto Import:自动提示和导入依赖
  • Auto Rename Tag:自动修改配对Tag
  • Tailwind CSS IntelliSense:智能提升Tailwind样式雷
  • Prettier:格式化代码
  • ESLint:代码格式及出错提示
  • vscode-icons:文件图标

项目实践

本文基于macOS进行演示,其他平台请自行切换部分包的安装方法

新建Electorn项目

安装yarn

$ brew install yarn

如果macOS上没有brew可以使用:/bin/bash -c "$(curl -fsSL https://gitee.com/ineo6/homebrew-install/raw/master/install.sh)" 安装

使用@quick-start/electron创建electorn项目

$ yarn create @quick-start/electron

选择参考如下

✔ Project name: … note-mark
✔ Select a framework: › react
✔ Add TypeScript? … Yes
✔ Add Electron updater plugin? … No 
✔ Enable Electron download mirror proxy? … No 

进入项目并安装依赖

cd note-mark
yarn

项目结构

VSCode打开项目

image

新建(补充)一些目录

# 新建共享(通用)类型及常量目录shared
mkdir src/shared

# 新建后端实用方法目录lib
mkdir src/main/lib

# 新建前端钩子方法目录hooks
mkdir src/renderer/src/hooks

# 新建前端实用方法目录utils
mkdir src/renderer/src/utils

# 新建前端数据目录store及虚拟数据目录mocks
mkdir src/renderer/src/store
mkdir src/renderer/src/store/mocks

项目整体结构说明如下

├──.vscode
├──build   // 打包相关资源
├──node_modules  // 三方依赖包
├──resources        // 资源目录
│   ├── icon.png  // 应用图标
├── src   // 源码目录
│   ├── main  // 主进程目录
│   │   ├── index.ts  // 入口文件
│   │   └── lib // 自定义方法目录
│   ├── preload // 预加载脚本目录
│   │   ├── index.d.ts  // 在context上下文中声明自定义变量类型
│   │   └── index.ts    // 在contextBridge中定义变量及函数调用的ipc方法
│   ├── renderer // 渲染进程目录
│   │   ├── index.html  // 索引页面,引入src/main.tsx
│   │   └── src
│   │       ├── App.tsx  // 应用根组件,引入components中各种组件
│   │       ├── assets  // 静态资源目录
│   │       │   ├── index.css  // 全局样式
│   │       ├── components  // 组件目录
│   │       ├── env.d.ts  // vite环境配置
│   │       ├── hooks  // 钩子目录
│   │       ├── main.tsx  // 入口文件,引入assets/index.css及App.tsx
│   │       ├── store  // 状态管理目录
│   │       └── utils  // 实用方法目录
│   └── shared  // 全局脚本目录
│       ├── constants.ts // 常量
│       ├── models.ts  // 数据类型
│       └── types.ts  // 函数类型
├── resources  // 资源目录
│   └── icon.png  // 应用图标
├── electron-builder.yml  // electron打包配置
├── electron.vite.config.ts  // vite构建配置
├── package.json   // 项目配置文件,包含项目信息、依赖及脚本(命令)等
├── tsconfig.json  // typescript编译配置,引入tsconfig.node.json及tsconfig.web.json
├── tsconfig.node.json // typescript配置
├── tsconfig.web.json  // typescript配置
└── README.md   // 项目说明

preload: 为了将 Electron 的不同类型的进程桥接在一起,我们需要使用被称为 预加载 的特殊脚本

启动app

运行

yarn dev

示例app初始界面如下:

image

Electron通信原理

从项目src目录中可以看到main、preload和renderer三个主要目录

Electorn应用分为主进程main及浏览器渲染进程renderer两个进程,类似于后端和前端,其中preload起通信桥梁的作用。

image

通信示意图如下:

graph LR Config[package.json] -->|启动| Main[src/main/index.ts] subgraph 渲染进程renderer-前端 RedererIndex[src/renderer/index.html]-->|导入|RedererMain[src/renderer/src/main.ts] RedererMain-->|样式|RedererCSS[src/renderer/src/assets/index.css] RedererMain-->|根组件|App[src/renderer/src/App.tsx] App-->|包含|components[src/renderer/src/components/*.tsx] end subgraph 预加载preload-通信桥梁 Preload[src/preload/index.ts] -->|contextBridge|RedererIndex end subgraph 主进程main-后端 Main -->|IPC通信|Preload end

配置项目窗口样式

为了应用美观,我们将窗口改为无标题栏模式,修改如下:

修改src/main/index.ts

...
function createWindow(): void {
  // Create the browser window.
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),

    center: true,  // 新增
    title: 'NoteMark',  // 新增
    frame: false,  // 新增
    vibrancy: 'under-window',  // 新增
    visualEffectState: 'active',  // 新增
    titleBarStyle: 'hidden',  // 新增
    trafficLightPosition: { x: 15, y: 10 },  // 新增

    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: true,
      contextIsolation: true
    }
  })
 ...

修改前端index.html
在src/renderer/index.html内容安全策略中增加允许执行不安全脚本,即script-src 'self;'修改为script-src 'self' 'unsafe-eval';
同时删除不用的title等,修改后index.html内容如下:

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
    />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

删除应用根组件App.ts中的多余内容及assets和components中的多余资源和组件,

  1. 删除src/renderer/src/assets除main.css之外所有内容,并清空main.css文件
  2. 删除src/renderer/src/comppents/Version.tsx组件
  3. 修改根应用App.tsx内容,删除多余内容,并只添加一个文本,例如

App.tsx

function App(): JSX.Element {
  return (
    <>

    </>
  )
}

export default App

此时应用界面如下
image

安装TailwindCSS

安装tailwind样式

yarn add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

执行后在项目根目录生成tailwind.config.js和postcss.config.js
修改tailwind.config.js,content中增加'./src/renderer/**/*.{js,ts,jsx,tsx}'
完整内容如下
tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/renderer/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {}
  },
  plugins: [require('@tailwindcss/typography')]
}

安装其他三方包

yarn add -D tailwind-merge
yarn add -D clsx
yarn add -D react-icons

添加主体样式
src/renderer/src/assets/main.css

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  #root {
    @apply h-full;
  }

  html,
  body {
    @apply h-full;
    @apply select-none;
    @apply bg-transparent;
    @apply font-mono antialiased text-white;
    @apply overflow-hidden;
  }

  header {
    -webkit-app-region: drag;
  }

  button {
    -webkit-app-region: no-drag;
  }

  ::-webkit-scrollbar {
    @apply w-2;
  }

  ::-webkit-scrollbar-thumb {
    @apply bg-[#555] rounded-md;
  }

  ::-webkit-scrollbar-track {
    @apply bg-transparent;
  }
}

创建左右布局

添加React组件

[!tips] .ts文件、.d.ts .tsx、文件的区别

  • ts:TypeScript 脚本文件
  • d.ts: 允许在TypeScript中使用现有JavaScript代码的类型定义文件
  • tsx:React + 类型检查

React组件基本模板

import { ComponentProps } from 'react'
import { twMerge } from 'tailwind-merge'

export const 组件名 = ({ className, ...props }: ComponentProps<'div'>) => {

  return (
    <div className={twMerge('flex justify-center', className)} {...props}>
      
    </div>
  )
}

创建AppLayout组件
在src/renderer/src/components中创建AppLayout.tsx,参考内容如下

AppLayout.tsx

import { ComponentProps, forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'

export const RootLayout = ({ children, className, ...props }: ComponentProps<'main'>) => {
  return (
    <main className={twMerge('flex flex-row h-screen', className)} {...props}>
      {children}
    </main>
  )
}

export const Sidebar = ({ className, children, ...props }) => {
  return (
    <aside
      className={twMerge('w-[250px] mt-10 h-[100vh+10px] overflow-auto', className)}
      {...props}
    >
      {children}
    </aside>
  )
}

export const Content = forwardRef<HTMLDivElement, ComponentProps<'div'>>(
  ({ children, className, ...props }, ref) => (
    <div ref={ref} className={twMerge('flex-1 overflow-auto', className)} {...props}>
      {children}
    </div>
  )
)

Content.displayName = 'Content'

此处定义了一个根布局组件RootLayout,一个侧边栏组件Sidebar和一个主体内容组件Content。

根组件App.tsx中导入布局组件
src/renderer/src/App.tsx

import { Content, RootLayout, Sidebar } from "./components/AppLayout"

function App(): JSX.Element {
  return (
    <>
      <RootLayout>
        <Sidebar className="p-2">
          Sidebar
        </Sidebar>
        <Content className="border-l bg-zinc-900/50 border-l-white/20">
        Content
        </Content>
      </RootLayout>
    </>
  )
}

export default App

启动App,显示如下

image

添加可拖拽头

由于我们隐藏了titleBar,此时窗口没办法拖拽,这里我们添加一个自定义header组件来实现支持拖拽。

新建src/renderer/src/components/DraggableTopBar.tsx

export const DraggableTopBar = () => {
  return <header className="absolute inset-0 h-8 bg-transparent" />
}

App.tsx中导入组件

import { Content, RootLayout, Sidebar } from "./components/AppLayout"
import { DraggableTopBar } from "./components/DraggableTopBar"

function App(): JSX.Element {
  return (
    <>
      <DraggableTopBar />
      <RootLayout>
        <Sidebar className="p-2">
          Sidebar
        </Sidebar>
        <Content className="border-l bg-zinc-900/50 border-l-white/20">
        Content
        </Content>
      </RootLayout>
    </>
  )
}

export default App

运行App,此时App,点击TopBar区域,可拖拽移动窗口位置。

侧边栏添加按钮

在src/renderer/src/components中新建Button目录
新建通用按钮组件

src/renderer/src/components/Button/ActionBuuton.tsx

import { ComponentProps } from 'react'
import { twMerge } from 'tailwind-merge'

export type ActionButtonProps = ComponentProps<'button'>

export const ActionButton = ({ className, children, ...props }: ActionButtonProps) => {
  return (
    <button
      className={twMerge(
        'px-2 py-1, rounded-md border border-zinc-400/50 hover: bg-zink-600/50 transition-all duration-100',
        className
      )}
      {...props}
    >
      {children}
    </button>
  )
}

新建笔记按钮
新建src/renderer/src/components/Button/NewNoteBuuton.tsx

import { ActionButton, ActionButtonProps } from './ActionButton'
import { LuFileSignature } from 'react-icons/lu'

export const NewNoteButton = ({ ...props }: ActionButtonProps) => {
  
  const handleCreation = async () => {
    console.info("创建笔记")
  }
  
  return (
    <ActionButton onClick={handleCreation} {...props}>
      <LuFileSignature className="w-5 h-5 text-zinc-300" />
    </ActionButton>
  )
}

删除笔记按钮
新建src/renderer/src/components/Button/DeleteNoteButton.tsx

import { ActionButton, ActionButtonProps } from './ActionButton'
import { FaRegTrashCan } from 'react-icons/fa6'

export const DeleteNoteButton = ({ ...props }: ActionButtonProps) => {
  
  const handelDelete = async () => {
    console.info("删除笔记")
  }
  
  return (
    <ActionButton onClick={handelDelete} {...props}>
      <FaRegTrashCan className="w-5 h-5 text-zinc-300" />
    </ActionButton>
  )
}

添加一个按钮栏,并引入新建笔记及删除笔记按钮
新建src/renderer/src/components/ActionButtonRow.tsx

import { ComponentProps } from 'react'
import { NewNoteButton } from './Button/NewNoteButton'
import { DeleteNoteButton } from './Button/DeleteNoteButton'

export const ActionButtonRow = ({ ...props }: ComponentProps<'div'>) => {
  return (
    <div {...props}>
      <NewNoteButton />
      <DeleteNoteButton />
    </div>
  )
}

根组件App.tsx中导入按钮栏ActionButtonRow

将macOS系统切换到深色模式,启动App显示如下

此处App样式仅适配了深色模式

image

使用mock数据

image

添加预览

image

格式化日期

image

image

添加Markdown编辑器

MDXEditor - the Rich Text Markdown Editor React Component

yarn add @mdxeditor/editor
yarn add -D @tailwindcss/typography

image

image

修改滚动条

src/renderer/src/assets/index.css添加

// ....
@layer base {
    // ...
    
    ::-webkit-scrollbar {
        @apply w-2;
    }

    ::-webkit-scrollbar-thumb {
        @apply bg-[#555] rounded-md;
    }

    ::-webkit-scrollbar-track {
        @apply bg-transparent;
    }
}

image

添加Note Title

image

yarn add jotai

image

image

修改src/renderer/src/components/MarkdownEditor.tsx

// ...
return <MDXEditor
        key={selectedNote.title}
        markdown={selectedNote.content}
        // ...
/>

image

重置滚动条

实现新增删除笔记

image

使用文件系统

File system | Node.js v22.9.0 Documentation

mkdir ~/MarkNote
cd ~/MarkNote
echo "Hello from Note1" > Note1.md
echo "Hello from Note2" > Note2.md
yarn add fs-extra
yarn add -D lodash
yarn typecheck:web

image

写入文件

src/shared/types.ts

// ...

export type WriteNote =(title: NoteInfo['title'], content: NoteContent) => Promise<void>

添加Welcome

构建

构建

yarn build
yarn start

构建mac应用

yarn build:mac

参考

posted @ 2024-09-29 11:01  韩志超  阅读(115)  评论(0编辑  收藏  举报