Electron实现你自己的Markdown编辑软件
前言
公众号:【可乐前端】,期待关注交流,分享一些有意思的前端知识
在上一期我们已经实现了文件管理的功能,这篇文章主要会介绍文件顶部栏实现、Markdown编辑器的主体接入以及图床实现。
顶部栏
我们会实现一个顶部栏去管理当前打开的文件,这里主要用到的是antd
的Tabs
组件。整体的交互如下:
- 点击左侧树的文件时:
- 当前文件不在顶部栏内,打开比聚焦该文件
- 如果已经存在,聚焦该文件
- 点击顶部栏的
tab
,切换到改文件进行编辑 - 点击关闭按钮,关闭对应的
tab
点击左侧树的时候,可以如下实现:
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
对应的文件路径去读取相应的文件内容。此时渲染进程可以向主进程发送一个事件,主进程读取到文件内容之后返回给渲染进程,渲染进程再交给编辑器处理。
useEffect(() => { if (!activeKey) { return } ipcRenderer.send(GET_FILE, activeKey) }, [activeKey])
监听到当前活跃的tab
变更之后,向主进程发送事件,主进程接收到事件之后,就可以进行如下的读取文件操作:
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
置空。
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
,这是一个代码高亮的插件。
安装完对应的依赖之后,就可以通过十分简单的代码来使用这个组件了。
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
以及更新对应文件的内容。
const updateFile = useCallback( debounce((value) => { ipcRenderer.send(UPDATE_FILE, activeKey, value) }, 300), [activeKey] )
实现一个updateFile
来更新文件的内容,这里我加了一个防抖函数,让IO
不要太过频繁。同样也是发送一个事件给主进程,让主进程去写文件。
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
了:
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
的接口,这样就实现了在编辑器中上传图片。
const handleUpload = async (files) => { const urls = await Promise.all( files.map(async (file) => { const url = await uploadImageToGitHub(file) return { url } }) ) return urls }
最后
到这里我们就已经实现了自己的Markdown
编辑软件,这篇文章就是在这个软件下写出来的。大体功能没有什么问题,就是还有一些小的交互细节可以持续去优化一下。如果你觉得有意思的话,点点关注点点赞吧~