在 写文章体验很不好,文档样式少,代码样式难看,且代码经常丢样式,其他平台如知乎等体验也不好。如果想在多平台发文章就更痛苦,每个平台都要单独调整一遍样式,非常费时。市面上也找不到一款非常合适的编辑器,感觉都比较鸡肋,尤其是文章插入图片等媒体时,很难转移到其他平台。
所以我打算自己开发个Markdown编辑器,能够自定义样式,转到如 等平台时文中的图片等媒体可轻松上传。
所以主要目标:
可自定义元素样式 – 通过自己写css来控制元素样式
好看的代码样式 – 支持多种编程语言,且样式可选
轻松上传多家平台 – 先实现 ,后期再接入知乎、掘金等
技术难点
技术上主要难点有几方面
编辑器 – 好用且有高亮提示的编辑功能
Markdown解析 – 将Markdown内容转成html
代码高亮 – Markdown中的代码转成html后要有代码格式、关键字高亮
上传平台 – 如何将带有样式片等多媒体内容的文章上传(或粘贴)到不同平台
上传平台功能目前考虑使用复制粘贴方式,具体实现后面再详细设计,先具体实现编辑功能。编辑器与Markdown解析器都有优秀的开源项目,所以只要选择合适的开源库即可,感谢开源贡献者们。编辑器我们使用微软的monaco-editor;Markdown解析器我们使用marked;代码高亮用hightlight.js。搭建工程
我们先构建web形式的工程,实现基本的编辑功能,后期再考虑用Electron封装成本地。UI部分并不复杂,主要是相关功能的逻辑整合,所以我直接使用自己的工程模板来搭建。
# 克隆工程 git clone --depth=1 git@github.com:guofei0723/ltrtb.git stylemd # 进入工程 cd stylemd # 删除模板的git数据 rm -rf .git # 初始化项目git git init # 安装工程依赖 yarn # 安装项目需要的库 yarn add monaco-editor marked # 启动工程 yarn start

工程可以启动,说明一切正常。下面来做基本的布局,核心区域为左右结构,左侧编辑,右侧预览。修改src/App.tsx
export default () => (
<div className="h-screen w-screen flex bg-teal-900">
<div className="main-left flex-1 h-full bg-gray-900 text-white">
这边是编辑器
</div>
<div className="slider w-1 h-full" />
<div className="main-right flex-1 h-full bg-white">
这边是预览
</div>
</div>
)

左侧编辑器
现在来实例化monaco-editor,首先为编辑器单独创建一个组件,放在src/components/editor.tsx
import React from 'react'
interface EditorProps {
}
const Editor: React.FC<EditorProps> = () => (
<div className="w-full h-full">
我是编辑器
</div>
)
export default Editor
再将页面左侧内容改为编辑器。
<div className="main-left flex-1 h-full bg-gray-900 text-white"> <Editor /> </div>

monaco-editor使用了worker来处理代码内容,由于支持的语言很多所以对应的worker也很多。我们是基于webpack的工程,打包时可以仅包含我们需要的编程语言支持,monaco-editor提供了相应的webpack插件,以方便对其集成。先安装webpack插件
yarn add -D monaco-editor-webpack-plugin
在webpack配置中添加该插件
// ...
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin')
// ...
{
// ...
plugins: [
new MonacoWebpackPlugin({
languages: ['css', 'markdown'],
}),
]
}
由于是在做Markdown编辑器,而且要支持通过css自定义样式,所以编程语言需要Markdown与css。重启工程,然后在组件中创建编辑器对象,Editor.tsx修改为以下内容
import React, { useEffect, useRef } from 'react'
import * as monaco from 'monaco-editor'
interface EditorProps {
}
const Editor: React.FC<EditorProps> = () => {
// 外层div的ref
const wrapperRef = useRef<HTMLDivElement>(null)
// 编辑器实例
const editorIns = useRef<monaco.editor.IStandaloneCodeEditor>()
// 一些初始化
useEffect(() => {
// 创建编辑器实例
editorIns.current = monaco.editor.create(wrapperRef.current as HTMLElement, {
language: 'markdown',
// 黑色风格
theme: 'vs-dark',
})
}, [])
return (
<div className="w-full h-full" ref={wrapperRef} />
)
}
export default Editor
目前效果

现在可以正常输入内容了,但还不能预览,下面来做预览功能。
预览功能
首先要处理的是把Markdown解析成html,我们通过marked来实现。创建预览器组件previewer.tsx
import React, { useEffect, useState } from 'react'
import marked from 'marked'
interface PreviewerProps {
}
const demoMarkdown =
# 这里当作是标题
## here should be h2
------
* list item 1
* list item 2
> hello
> Nice to meet you
\\\
console.log('Hello, World!');
\\\
bye
const Previewer: React.FC<PreviewerProps> = () => {
const [content, setContent] = useState('')
useEffect(() => {
setContent(marked(demoMarkdown))
}, [])
return (
<div>
{content}
</div>
)
}
export default Previewer
这里我们先硬编码了一段Markdown内容用于测试,功能没问题后再动态解析左侧输入的内容。将页面右侧改为预览器组件
<div className="main-right flex-1 h-full bg-white"> <Previewer /> </div>
效果

因为我们现在是直接输出的内容,并没有作为元素插入页面,所以现在看到的是html源码。现在说明marked是正常工作了,下面来把解析结果插入页面,修改previewer.tsx
// ...
return (
// eslint-disable-next-line react/no-danger
<div dangerouslySetInnerHTML={{ __html: content }} />
)
// ...
效果

现在的确是被作为元素插入页面,但却没有任何样式,这是因为受当前页面全局css的影响。由于工程使用的是tailwindcss,会清除所有默认样式,所以这些插入的元素也没有任何默认样式。为了不让预览的样式受页面样式的影响,我们把预览内容放到iframe中,修改previewer.tsx
// ...
return (
<iframe
title="previewer"
srcDoc={content}
className="w-full h-full"
/>
)
// ...

好的,预览内容独立出来后可以看到浏览器的默认样式了。现在考虑动态解析左侧编辑器中的内容。
数据共享
由于左侧编辑器与右侧预览器是相互独立的,所以考虑使用context将编辑器中的内容共享出来。
新建store/docdata.tsx文件
import React, { useContext, useState } from 'react'
const DocContext = React.createContext<[
string,
React.Dispatch<React.SetStateAction<string>>,
] | null>(null)
export function DocProvider({ children } : React.PropsWithChildren<{}>) {
const docState = useState('')
return (
<DocContext.Provider value={docState}>
{children}
</DocContext.Provider>
)
}
export const useDocState = () => useContext(DocContext) as DocState
使用DocProvider包装整个app,修改src/App.tsx
// ...
export default () => (
<DocProvider>
{/* ... */}
</DocProvider>
)
修改editor.tsx,使其共享出编辑的文档内容
// ...
import { useDocState } from '@/store/docdata'
// ...
// 拿到设置共享doc的方法
const [, setDoc] = useDocState()
// 一些无需react监听的状态数据
const status = useRef({
// 更新内容的计时器
updateTimer: -1,
}).current
// 一些初始化
useEffect(() => {
// 创建编辑器实例
editorIns.current = monaco.editor.create(wrapperRef.current as HTMLElement, {
language: 'markdown',
// 黑色风格
theme: 'vs-dark',
})
const { current: edt } = editorIns
// 监听内容变化
edt.onDidChangeModelContent(() => {
// 如果当前没有倒计时更新,则5秒后更新
if (status.updateTimer < 0) {
status.updateTimer = window.setTimeout(() => {
// 标识已经完成更新
status.updateTimer = -1
// 更新内容
setDoc(edt.getValue())
}, 5000)
}
})
}, [])
修改previewer.tsx,当内容变化时自动解析
// ...
import { useDocState } from '@/store/docdata'
// ...
// 获取编辑的内容
const [doc] = useDocState()
// 文档内容变化时更新
useEffect(() => {
setContent(marked(doc))
}, [doc])
// ...

自动解析已经可用,后面重点就是实现样式与插入图片了。今天时间已经不早,姑且先到这
坚果之云 Markdown