前言
最近在写自己的博客站,一个博客站,肯定需要有自己写博客的地方,所以一开始定了几个方案:
- 通过静态文件的方式,直接在站点内贴出文件地址
- 在其他站点发好文章,本站直接iframe或者其他方式嵌入
- 后台使用富文本编辑器的方式,后台编辑发布
- 前台使用markdown编辑器,前台账号登陆后编写发布
第一种方式太过于死板麻烦,第二种看起来逼格太低切low,第三种靠谱点,但是这种方式就限制死了发文章的方式。所以本着热爱学习(瞎找事情白折腾)的心态,决定自己在站内引入一下markdown编辑器来编辑发布文章
开始
通过网络上一番简单筛选,没有找到比较满意(现成)的库和插件,所以就决定按照网上实现的思路,引入第三方依赖来实现一个简单的markdown编辑器
主要用到的模块
CodeMirror
codemirror
是一款功能非常丰富也比较基础的一款javascript
编辑器,有很多功能强大的js编辑器都是基于codemirro
来实现的,CodeMirror官网介绍说codemirror
拥有超过100种语言模式和各种附加组件,并且能实现更高级的功能,并且每种语言都带有功能齐全的代码和语法突出显示,以帮助阅读和编辑复杂的代码。
还有值得一提的就是codemirror
的logo,好像是一直猴子在照镜子,在结合命名直译为代码的镜子,是不是就寓意着这个插件就像是程序猿照镜子一样有苦无处言呢(瞎猜的哈...)(而且为啥红脸😳)
Tips: 这部分主要是简介了codemirror的相关用法,略繁琐,如果想看具体开发过程的同学可以跳过这一部分,直接到 '开发markdown模块'
用法
- 需要定义一个容器,然后通过
CodeMirror.fromTextArea(容器,初始化配置参数)
将容器转换为编辑器 - 设置编辑器语言,初始化配置参数mode可以设置语言包类型
- 设置主题,在初始化配置参数中设置主题theme
- 其他更多个性化配置(本次没用上,就不展开了)
可选语言包
codemirror有丰富的语言包支持包括了:
javascript,clike,go,htmlmixed,http,php,python,http,sql,vue,xml
等语言
可选主题
codemirror有非常多丰富的主题,找到主题的方式有两种:
-
在CodeMirror官网主题demo 中快速选择,然后找到满意的主题
-
上种方式是常规操作,但是由于本人比较懒,并且
codemirror
官网访问比较慢,所以能我直接在node_modules中的codemirror/theme/中随便挑了个主题了 -
部分主题列表
blackboard cobalt colorforth darcula dracula duotone-dark duotone-light eclipse elegant tomorrow-night-bright tomorrow-night-eighties
初始化配置参数
- value:初始内容
- Mode:设置编译器编程语言关联内容,对应的mine值
- Theme:编译器的主题,需要引入对应的包
- tabSize:tab的空格宽度
- lineNumbers:是否使用行号
- smartIndent:自动缩进是否开启
- indentUnit:缩进单位
- keyMap:快捷键,default使用默认快捷键(codemirror/keymap/xxx)
- extraKeys 快捷键(codemirror/addon/hint/xxx)
- extraKeys的快捷键绑定命令 - 具体命令到官网查看
- scrollbarStyle: 设置滚动条,默认为"null"为不显示的滚动条 (codemirror/addon/scroll/xxx)
- readOnly:设置为只读true/false;也可设置为"nocursor"失去焦点
- Autofocus:初始时是否自动获取焦点boolean
- styleActiveLine: 设置光标所在行高亮true/false,需引入工具包(codemirror/addon/selection/active-line)
- ...
动态配置参数
上面介绍了部分的静态参数,但是有时候我们需要灵活改变,所以需要能够动态的设置参数,codemirror
提供了一个apisetOption
,可以动态设置参数,在初始化编辑器之后会返回一个编辑器实例const editor = CodeMirror.fromTextArea(el, options)
,然后就可以设置动态参数editor.setOption('某个配置项譬如theme', 新的值)
事件
- changes 每次编辑器内容更改时触发
- beforeChange 事件在更改生效前触发
- focus 编辑器收到焦点时触发
- blur 编辑器失去焦点时触发
- scroll 编辑器滚动条滚动时触发
- keyHandled 快捷键映射后被处理触发
- inputRead 当用户输入或粘贴时编辑器时触发。
- electrictInput 收到指定的输入时触发
- cursorActivity 当光标或选中(内容)发生变化,或者编辑器的内容发生了更改的时候触发。
- beforeSelectionChange 此事件在选中内容变化前触发
- viewportChange 编辑器视窗发生变化时触发
- gutterClick 编辑器的行号区域点击时触发
- 其他的键盘事件keydown, keypress, keyup, mousedown, dblclick
- ...
监听事件也比较简单,实例化编辑器之后
editor.on('EventName', EventCallback)
,EventName: 事件名, EventCallback: 事件触发的回调函数
有监听,肯定也会有取消监听,通过
editor.off('EventName')
来取消事件订阅
API
- setValue 设置编辑器内容
- getValue 获取编辑器内容
- getLine 获取第n行的内容(可以配合lineNumbers一起使用)
- lineCount 获取当前行数
- lastLine 获取最后一行的行号
- isClean 类型判断编译器是否是干净
- getSelection 获取选中内容
- getSelections 返回array类型选中内容
- replaceSelection 替换选中的内容
- getCursor 获取光标位置
- setOption 设置编译器属性
- getOption 获取编译器属性
- setSize 设置编译器大小
- scrollTo 设置编辑器的滚动位置
- refresh 刷新编辑器
- ...
指令列表
-
selectAll
Ctrl-A (PC), Cmd-A (Mac) 选择所有 -
singleSelection
Esc 取消选择 -
killLine
Ctrl-K (Mac) 删除光标后的部分 -
deleteLine
Ctrl-D (PC), Cmd-D (Mac) 删除行 -
...
NOTE: 指令实在太多了,小弟实在抄不过来了...
其他
常用的功能基本都描述过了,其他的一些vim API什么的就不多展开说了,有兴趣的同学可以上CodeMirror官网看看哈
react-markdown
使用react-markdown来解析markdown文件,入门用法比较简单,直接就可以简单用了
参数
- source/children 解析的源字符串 (唯一的必填项)
- escapeHtml 转化为html
- skipHtml 忽略内联和html块
- renderers 自定义节点类型的渲染方式
- plugins 解析的插件
- 其他...(感兴趣的同学可以到github上阅读详细参数)
节点类型
- code 代码块
- table
- root
- ...
其它用法🔎还在踩坑中...
开发Markdown模块
组件化思维,把整个项目按最小单一化原则来分为n个细粒度的模块,使得整个项目更加灵活通用可配置
编辑器
模块的编辑肯定离不开编辑器,这边借助codemirror强大功能的一小部分,来实现简单的编辑器模块
import React, { useEffect, useRef } from 'react'
import CodeMirror from 'codemirror'
// 语言类型
import 'codemirror/mode/markdown/markdown'
// 快捷键
import 'codemirror/keymap/sublime.js'
//NOTE: 引入样式一直没成功,还没找到原因 -.-
// 主题样式
// import 'codemirror/theme/ayu-mirage.css'
// 主要样式
// import 'codemirror/lib/codemirror.css'
const Editor:React.SFC<TProps> = ({
value,
onChange,
textAreaClassName,
readOnly,
handleChange,
...slot
}) => {
const editorRef = useRef(null)
useEffect(() => {
// 初始化编辑器
const editor = CodeMirror.fromTextArea(editorRef.current as any, {
mode: 'markdown',
theme: 'ayu-mirage',
{...slot}, // 其他参数可以由外部传入 - 方便拓展
})
// 监听内容变化
editor.on('change', (evt: any) => {
const val = evt.getValue()
handleChange(val)
})
}, [])
// 主要是用来挂载编辑器
// 其实开发过程中只用到了ref(个人感觉用div+contenteditable也能实现0.0)
return (
<textarea
ref={editorRef}
value={value}
readOnly={readOnly}
onChange={onChange}
className={textAreaClassName}
/>
)
}
这边有个问题,就是引入codemirror样式的时候,一直没有效果,也不知道为啥,所以样式库就采用cdn的方式来加载了
<!-- 基础样式 -->
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/codemirror/5.54.0/codemirror.min.css" />
<!-- 主题样式, 需要和初始化时的theme绑定引入,如果主题要切换的话,需要引入多个样式 -->
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/codemirror/5.54.0/theme/ayu-mirage.min.css" />
预览器
有了编辑之后,肯定少不了预览查看,所以这边借助react-markdown
来做预览功能
🔔🔔🔔不同于codemirror编辑器,react-markdown的样式优先级是比较低的,所以千万千万千万不要全局引入譬如reset.css等重置样式的库(真是欲哭无泪😭😭)
// view.tsx
// 这边直接用html版本, 如果直接用原始版本,转化为html还需要引入插件来完成
import ReactMarkDown from 'react-markdown/with-html'
// markdown 编辑器
const MarkDownView: React.SFC<IProps> = ({
content,
...slot, // 外部可以拓展自定义的参数 - 更灵活
}) => {
return (
<ReactMarkDown
escapeHtml={false}
skipHtml={false}
source={content}
renderers={{
code: CodeBlock,
table: TableBlock,
}}
{...slot}
/>
)
}
MarkDownView.defaultProps = {
content: '',
}
export default MarkDownView
上面的代码,仅仅是一些简单的markdown预览,它本身的code view是比较简陋的,所以在预览代码块的时候,需要定制一下renderers的code,优化一下代码的展示
// codeblock.tsx
import React from 'react'
import SyntaxHighlighter from 'react-syntax-highlighter'
// 主题样式
import { tomorrowNightEighties } from 'react-syntax-highlighter/dist/esm/styles/hljs'
interface IProps {
value: string
}
const CodeBlock: React.SFC<IProps> = ({
value
}) => (
<SyntaxHighlighter style={tomorrowNightEighties}>
{value}
</SyntaxHighlighter>
)
CodeBlock.defaultProps = {
value: ''
}
export default CodeBlock
不知道为啥,预览器的表格样式一直没出来,无奈之下,只能自己重写一下表格样式了
// tableblock.tsx
// 重新用样式容器包裹一下组件
const Table = styled.table`
th, td {
text-align: center;
padding: 6px 13px;
border: 1px solid #abcdef;
}
thead {
background: #abcdef;
}
tbody {
tr {
background: #fff;
&:nth-of-type(2n) {
background: #dfe2e5;
}
}
}
`
const TableBlock: React.SFC<IProps> = ({
children,
}) => (
// 因为就简单的写了一下样式,所以没有破坏组件本身
<Table>{children}</Table>
)
到这一步主要的工作都做好了,剩下的就是一些优化工作,还有一些辅助工具的开发,譬如预览导航效果
预览导航
其实导航效果也比较简单,用html锚点就可以实现
- 首先设置锚点,这边通过自己重写h系列标签到的方式来包装设置锚点
// heading.tsx
// 锚点
const Anchor: React.SFC<any> = ({ level, children, ...props }) => React.createElement(`h${level}`, props, children)
const Heading: React.SFC<IProps> = ({
level,
children,
anchorMap,
handleAnchorChange,
}) => {
if (children && children.length > 0) {
const item = children[0].props
// 设置锚点地图
if (!anchorMap[item.nodeKey]) {
// 暴露出锚点信息
anchorMap[item.nodeKey] = {
level,
title: item.value,
anchor: item.nodeKey
}
handleAnchorChange(anchorMap)
}
// 重新包装锚点
return <Anchor level={level} id={item.nodeKey}>{children}</Anchor>
} else {
// 如果没有内容,就返回空
return <React.Fragment>{children}</React.Fragment>
}
}
// view.tsx
// 接受锚点数据
const [anchorMap, setAnchorMap] = useState<IAnchor>({})
<ReactMarkDown
renderers={{
// 重写包装锚点
heading: ({ level, children }) => (
<HeadingBlock
anchorMap={anchorMap}
handleAnchorChange={(map: IAnchor) => {
setAnchorMap(map)
// 处理并且暴露出锚点数据 - 使得锚点和预览器互相解藕,预览器只是为锚点导航提供数据
handleAnchorChange && handleAnchorChange(Object.values(map))
}}
level={level}
children={children}
/>
),
}}
{...slot}
/>
导航地图 - 跳转到锚点
// anchorNav.tsx
// 使用dl,dt,dd来做一个埋点效果
// 粗糙的样式...
const Box = styled.dl`
position: absolute;
top: 0;
right: 0;
z-index: 1;
padding: 10px;
line-height: 20px;
background: #abcdef;
box-shadow: 10px 10px 10px #efefef;
color: rgba(255, 255, 255, .5);
`
const AnchorNav: React.SFC<IProps> = ({
list
}) => {
// 接收数据并且展示 - 样式可以按个人喜好优化
return (
<Box>
{
list.map((v) => {
if (v.level === 1) {
return (
<dt key={v.anchor}>
<a href={`#${v.anchor}`}>{v.title}</a>
</dt>
)
} else {
return (
<dd key={v.anchor} className={`tab-${v.level}`}>
<a href={`#${v.anchor}`}>{v.title}</a>
</dd>
)
}
})
}
</Box>
)
}
这样以来,差不多markdown预览和预览导航部分也就基本完成了🎉🎉
功能整合
最后面需要有一个入口,整合所有的功能,并且作为一个默认的暴露对象暴露出去供外面使用
import React, { useState } from 'react'
import styled from 'styled-components'
import Editor from './editor'
import View from './view'
import AnchorNav from './anchorNav'
const Wrap = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
z-index: 1;
`
const EditorBox = styled.div`
width: 50%;
height: 100%;
border: 1px dashed #efefef;
.CodeMirror {
width: 100%;
height: 100%;
}
`
const ViewBox = styled.div`
width: 50%;
height: 100%;
padding: 10px;
overflow: auto;
box-sizing: border-box;
border: 1px dashed #efefef;
`
const MarkDownEditor = () => {
const [val, setVal] = useState('')
const [anchorList, setAnchorList] = useState<Array<any>>([])
return (
<Wrap>
<EditorBox>
// 编辑器模块
<Editor
value={val}
onChange={(v: any) => {
setVal(v)
}}
handleChange={(text: string) => {
setVal(text)
}}
/>
</EditorBox>
// 预览模块
<ViewBox>
<View
content={val}
handleAnchorChange={setAnchorList}
/>
</ViewBox>
// 预览导航的样式,其实也可以由外部指定,但是为了简单,就没那么做了
<AnchorNav
list={anchorList}
/>
</Wrap>
)
}
export default MarkDownEditor
其他
-
抽离codemirror包,减少打包体积
// 抽离codemirror codemirror: { test: /[\\/]node_modules[\\/](codemirror)[\\/]/, name: 'static/js/codemirror', priority: 20, }
-
cdn引入相关模块对比es模块引入,通过analyzer分析,再加上分离包,感觉大小在可以接受的范围,所以这边就用es模块引入的方式来做(最主要的一点是es模块有代码提示,可以看源码了解代码大概的意思,官网加载速度实在是...)
-
组件化思维,把整个拆分分为 编辑器/预览器/公共容器,编辑器和预览器互相解耦且无状态,用公共容器来控制总体的效果,这样,其他时候需要用的时候,可以选择性的使用其中某一个或者灵活的自定义
-
编辑器图片不支持本地上传,必须要用网络资源
-
锚点平滑过度的优化 - 锚点跳转的时候加个简单的过渡效果,不会显得太生硬
部署
因为整个博客系统还没完成,所以这边单独把编辑器抽出来做一个展示,为了图个方便,就直接用serverless
架构来部署了
github地址 ✨✨✨✨✨
喜欢的朋友麻烦start👍一下哦
如果文中有哪些地方描述不恰当或者有误的地方,也希望能够批评指正哦