看完这篇文章,你也能手写一个markdown编辑器

3,736 阅读10分钟

前言

最近在写自己的博客站,一个博客站,肯定需要有自己写博客的地方,所以一开始定了几个方案:

  1. 通过静态文件的方式,直接在站点内贴出文件地址
  2. 在其他站点发好文章,本站直接iframe或者其他方式嵌入
  3. 后台使用富文本编辑器的方式,后台编辑发布
  4. 前台使用markdown编辑器,前台账号登陆后编写发布

第一种方式太过于死板麻烦,第二种看起来逼格太低切low,第三种靠谱点,但是这种方式就限制死了发文章的方式。所以本着热爱学习(瞎找事情白折腾)的心态,决定自己在站内引入一下markdown编辑器来编辑发布文章

斗图

开始

通过网络上一番简单筛选,没有找到比较满意(现成)的库和插件,所以就决定按照网上实现的思路,引入第三方依赖来实现一个简单的markdown编辑器

主要用到的模块

CodeMirror

codemirror是一款功能非常丰富也比较基础的一款javascript编辑器,有很多功能强大的js编辑器都是基于codemirro来实现的,CodeMirror官网介绍说codemirror拥有超过100种语言模式和各种附加组件,并且能实现更高级的功能,并且每种语言都带有功能齐全的代码和语法突出显示,以帮助阅读和编辑复杂的代码。

还有值得一提的就是codemirror的logo,好像是一直猴子在照镜子,在结合命名直译为代码的镜子,是不是就寓意着这个插件就像是程序猿照镜子一样有苦无处言呢(瞎猜的哈...)(而且为啥红脸😳)

logo

Tips: 这部分主要是简介了codemirror的相关用法,略繁琐,如果想看具体开发过程的同学可以跳过这一部分,直接到 '开发markdown模块'

用法
  1. 需要定义一个容器,然后通过CodeMirror.fromTextArea(容器,初始化配置参数)将容器转换为编辑器
  2. 设置编辑器语言,初始化配置参数mode可以设置语言包类型
  3. 设置主题,在初始化配置参数中设置主题theme
  4. 其他更多个性化配置(本次没用上,就不展开了)
可选语言包

codemirror有丰富的语言包支持包括了: javascript,clike,go,htmlmixed,http,php,python,http,sql,vue,xml等语言

可选主题

codemirror有非常多丰富的主题,找到主题的方式有两种:

  1. CodeMirror官网主题demo 中快速选择,然后找到满意的主题

  2. 上种方式是常规操作,但是由于本人比较懒,并且codemirror官网访问比较慢,所以能我直接在node_modules中的codemirror/theme/中随便挑了个主题了

  3. 部分主题列表

    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 刷新编辑器
  • ...
指令列表
  • selectAllCtrl-A (PC), Cmd-A (Mac) 选择所有

  • singleSelectionEsc 取消选择

  • killLineCtrl-K (Mac) 删除光标后的部分

  • deleteLineCtrl-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👍一下哦

如果文中有哪些地方描述不恰当或者有误的地方,也希望能够批评指正哦