Electron实现你自己的Markdown编辑软件

前言

公众号:【可乐前端】,期待关注交流,分享一些有意思的前端知识

在上一期我们已经实现了文件管理的功能,这篇文章主要会介绍文件顶部栏实现、Markdown编辑器的主体接入以及图床实现。

顶部栏

我们会实现一个顶部栏去管理当前打开的文件,这里主要用到的是antd的Tabs组件。整体的交互如下:

  • 点击左侧树的文件时:
    • 当前文件不在顶部栏内,打开比聚焦该文件
    • 如果已经存在,聚焦该文件
  • 点击顶部栏的tab,切换到改文件进行编辑
  • 点击关闭按钮,关闭对应的tab

点击左侧树的时候,可以如下实现:

js 复制代码
  const [tabs, setTabs] = useState([])
  const [activeKey, setActiveKey] = useState('')
  const [value, setValue] = useState('')
  const ipcRenderer = window.electron.ipcRenderer
  const handleSelect = (keys, { node }) => {
    const key = keys[0]
    if (!node.isLeaf) {
      return
    }
    const newTabs = [...tabs]
    const exist = newTabs.find((tab) => tab.key === key)
    if (!exist) {
      newTabs.push({ key, label: node.title })
      setTabs(newTabs)
    }
    setActiveKey(key)
  }
  // ...
   <Tabs
      hideAdd
      type="editable-card"
      activeKey={activeKey}
      onChange={(key) => setActiveKey(key)}
      onEdit={onEdit}
      items={tabs}
   />

这样点击的时候,对应的文件就会出现在顶部tab中,然后我们需要根据tab对应的文件路径取读取相应的文件内容。此时渲染进程可以向主进程发送一个事件,主进程读取到文件内容之后返回给渲染进程,渲染进程再交给编辑器处理。

js 复制代码
  useEffect(() => {
    if (!activeKey) {
      return
    }
    ipcRenderer.send(GET_FILE, activeKey)
  }, [activeKey])

监听到当前活跃的tab变更之后,向主进程发送事件,主进程接收到事件之后,就可以进行如下的读取文件操作:

js 复制代码
  ipcMain.on(GET_FILE, (event, key) => {
    try {
      const res = fs.readFileSync(key, { encoding: 'utf8' })
      event.sender.send(RECEIVE_FILE, res)
    } catch (error) {
      event.sender.send(COMMON_ERROR, '读取文件失败')
      event.sender.send(COMMON_ERROR_LOG, error)
    }
  })

这样渲染进程就可以获取到打开的文件内容。

然后来看一下移除标签页的逻辑,根据标签对应的key,从标签页数组中找到对应的项,检查是否要移除的标签页是当前活动标签页(activeKey),如果是的话,需要更新当前活跃的标签页。

如果移除的标签页不是最后一个标签页,就将当前活跃标签页设置为上一个标签页的key,否则设置为第一个标签页的key。最后,如果标签页数组为空,则把activeKey置空。

js 复制代码
  const onEdit = (targetKey) => {
    const remove = (targetKey) => {
      let newActiveKey = activeKey
      let lastIndex = -1
      tabs.forEach((item, i) => {
        if (item.key === targetKey) {
          lastIndex = i - 1
        }
      })
      const newPanes = tabs.filter((item) => item.key !== targetKey)
      if (newPanes.length && newActiveKey === targetKey) {
        if (lastIndex >= 0) {
          newActiveKey = newPanes[lastIndex].key
        } else {
          newActiveKey = newPanes[0].key
        }
      }
      if (newPanes.length === 0) {
        newActiveKey = ''
      }
      setTabs(newPanes)
      setActiveKey(newActiveKey)
    }
    remove(targetKey)
  }

顶部栏标签管理就实现到这里,下面我们来接入Markdown编辑器的主体。

编辑器主体

因为之前一直写文章用的都是掘金的Markdown编辑器,所以这里我也是直接接入了它的开源版本,由于我使用的是React技术栈,所以需要用到的包是@bytemd/react。

然后还可以安装一些常用的插件,比如@bytemd/plugin-gfm,它是专门处理 GitHub 风格的 Markdown 扩展语法(GitHub Flavored Markdown,简称 GFM)的插件。GFM 是 GitHub 对标准 Markdown 扩展的一种,引入了一些额外的功能,比如说任务列表、删除线、表格等;还有@bytemd/plugin-highlight,这是一个代码高亮的插件。

安装完对应的依赖之后,就可以通过十分简单的代码来使用这个组件了。

js 复制代码
import gfm from '@bytemd/plugin-gfm'
import highlight from '@bytemd/plugin-highlight'
import { Editor } from '@bytemd/react'
import 'bytemd/dist/index.css'
import zh from 'bytemd/locales/zh_Hans.json' //国际化json
import 'highlight.js/styles/default.css'
import './editor.css' // 额外的markdown主题样式
const plugins = [gfm(), highlight()]

const [value, setValue] = useState('')
<Editor
  uploadImages={handleUpload}
  mode="split"
  locale={zh}
  value={value}
  plugins={plugins}
  onChange={(v) => {
    setValue(v)
    updateFile(v)
  }}
/>

在内容更新的时候会触发一个onChange事件,这跟平时一般的input组件表现一致,这个时候我们需要更新组件的value以及更新对应文件的内容。

js 复制代码
  const updateFile = useCallback(
    debounce((value) => {
      ipcRenderer.send(UPDATE_FILE, activeKey, value)
    }, 300),
    [activeKey]
  )

实现一个updateFile来更新文件的内容,这里我加了一个防抖函数,让IO不要太过频繁。同样也是发送一个事件给主进程,让主进程去写文件。

js 复制代码
  ipcMain.on(UPDATE_FILE, (event, key, value) => {
    try {
      console.log('更新文件内容')
      fs.writeFileSync(key, value, { encoding: 'utf8' })
    } catch (error) {
      event.sender.send(COMMON_ERROR, '更新文件失败')
      event.sender.send(COMMON_ERROR_LOG, error)
    }
  })

这样我们的编辑器主体接入就完成了,可以开始快乐的写文章啦。

图床

写文章的时候怎么能不配图呢?配图怎么能少的了图床呢?所以这一小节是基于GitHub仓库来搭建了一个图床。

首先打开你的GitHub点击新建仓库(这里我由于已经创建过了所以显示仓库已存在。),然后打开GitHub token管理页面,新建一个token。

新建的时候把这里钩上

然后点击生成,token就新建好了,请注意保管好。

然后我们就可以开始尝试把文件上传到GitHub了:

js 复制代码
import axios from 'axios'
export const generateRandomFileName = (file) => {
  return `${window.crypto.randomUUID()}.${file.type.split('/')[1]}`
}

function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()

    reader.onload = () => {
      resolve(reader.result.split(',')[1])
    }

    reader.onerror = (error) => {
      reject(error)
    }

    reader.readAsDataURL(file)
  })
}

const token = '你的token'
const owner = '你的用户名'
const repo = '仓库名'
const commitMessage = 'Upload image to GitHub'
export const uploadImageToGitHub = async (file) => {
  try {
    const path = generateRandomFileName(file)
    // 构造请求头
    const headers = {
      Authorization: `token ${token}`,
      'Content-Type': 'application/json'
    }
    const imageContent = await fileToBase64(file)
    // 构造请求体
    const requestData = {
      message: commitMessage,
      content: imageContent,
      path: path
    }

    // 发送 HTTP 请求
    const response = await axios.put(
      `https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
      requestData,
      { headers }
    )

    // 输出上传结果
    console.log('Image uploaded successfully:', response.data)
    return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@main/${response.data.content.path}`
  } catch (error) {
    console.error('Failed to upload image to GitHub:', error.response.data)
    return ''
  }
}

让我们一起看看上面的代码做了什么:

  • generateRandomFileName 函数:接收一个文件对象 file,通过使用 window.crypto.randomUUID() 生成一个随机的文件名,保证文件名的唯一性,使用文件的类型(file.type)作为文件扩展名。

  • fileToBase64 函数:将文件对象转换为 base64 编码的字符串。

  • uploadImageToGitHub 函数:接收一个文件对象 file,通过调用前面两个函数,生成随机的文件名和将文件转换为 base64 编码的字符串。然后,使用 Axios 库发送一个 PUT 请求到 GitHub API 的 contents 端点,以上传文件。

上传成功之后,可以通过jsdelivr的CDN服务包裹一下我们的图片链接,让我们的图片资源访问的更快:https://cdn.jsdelivr.net/gh/${owner}/${repo}@main/${response.data.content.path}

到这里我们就已经实现了将File对象上传到GitHub的功能,剩下需要做的就是在编辑器组件中接入这个上传功能。

bytemd暴露了uploadImages这个属性,当我们在编辑器中上传、粘贴图片时会触发这个方法,我们可以在这里拿到我们在本地上传的图片,然后调用上传到GitHub的接口,这样就实现了在编辑器中上传图片。

js 复制代码
  const handleUpload = async (files) => {
    const urls = await Promise.all(
      files.map(async (file) => {
        const url = await uploadImageToGitHub(file)
        return {
          url
        }
      })
    )
    return urls
  }

最后

到这里我们就已经实现了自己的 Markdown编辑软件,这篇文章就是在这个软件下写出来的。大体功能没有什么问题,就是还有一些小的交互细节可以持续去优化一下。如果你觉得有意思的话,点点关注点点赞吧~

相关推荐
恋猫de小郭1 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60618 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了8 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅8 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅9 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment9 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端