在 写文章体验很不好,文档样式少,代码样式难看,且代码经常丢样式,其他平台如知乎等体验也不好。如果想在多平台发文章就更痛苦,每个平台都要单独调整一遍样式,非常费时。市面上也找不到一款非常合适的编辑器,感觉都比较鸡肋,尤其是文章插入图片等媒体时,很难转移到其他平台。
所以我打算自己开发个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]) // ...
自动解析已经可用,后面重点就是实现样式与插入图片了。今天时间已经不早,姑且先到这