掘金风格的 markdown编辑器
- 分上下两个模块
- 上面的标题栏和右侧的发布栏
- 内容区域分为左右2个区块
- 左侧为 markdown文本,用 textarea展示
- 右侧为 解析 markdown为 html的预览区
- 核心技术点
- 键盘事件的监听
- makdown格式的解析

解析 markdow用到的 npm
react-keyboard-event-handlerreact-markdownreact-mathjaxreact-syntax-highlighterreact-zmageremark-math
Header
头部分左右两栏
- 左侧是大标题
- 右侧是发布的相关操作 ```jsx import React, { useState } from ‘react’; import PropTypes from ‘prop-types’; import { Row, Col, Input, Button } from ‘antd’;
Header.propTypes = {
};
function Header(props) { const { title = ‘’, onChange } = props; const [loading, setLoading] = useState(false);
function onSubmit() { setLoading(true) }
return (
<Col span={6}><Publish /><Buttonloading={loading}type="primary"onClick={onSubmit}>保存草稿</Button><DropDownList /></Col></Row>
); }
export default Header;
<a name="OgY2v"></a>### 发布文章```jsxfunction Publish() {function Content() {return (<div>{categories?.map(category => (<CheckableTagkey={category.en_name}checked={category.id === selectedCategory}onChange={selected => checkCategorysHandle(category)}>{category.name}</CheckableTag>))}<h4>标签</h4>{tags?.map(tag => (<CheckableTagkey={tag.en_name}checked={tag.id === selectedTag}onChange={() => checkTagHandle(tag)}>{tag.name}</CheckableTag>))}<h4>文章封面图</h4><AliOssUpload type="click" returnImageUrl={returnCoverImageUrl} /><Button type="primary" onClick={onPublish}>发布文章</Button></div>)}return (<Popoverplacement="bottom"title={发布文章}overlayStyle={{ width: 300 }}trigger="click"content={Content()}><Button type="link"><CaretDownOutlined /> 发布</Button></Popover>)}export default Publish;
markdown语法提示

import { QuestionCircleOutlined } from '@ant-design/icons'<Popoverplacement="bottom"title="快捷键"overlayStyle={{ width: 400 }}content={<ShortCutKey />}><QuestionCircleOutlined /></Popover>function ShortCutKey() {const columns = [{title: 'Markdown',dataIndex: 'markdown',key: 'markdown',},{title: '说明',dataIndex: 'explain',key: 'explain',},{title: '快捷键',dataIndex: 'keybord',key: 'keybord',},]const dataSource = [{markdown: '## 标题',explain: 'H2',keybord: 'Ctrl / ⌘ + H',},{markdown: '**文本**',explain: '加粗',keybord: 'Ctrl / ⌘ + B',},{markdown: '*文本*',explain: '斜体',keybord: 'Ctrl / ⌘ + Alt + I',},{markdown: '[描述](链接)',explain: '链接',keybord: 'Ctrl / ⌘ + L',},{markdown: '',explain: '插入图片',keybord: 'Ctrl / ⌘ + I',},{markdown: '> 引用',explain: '引用',keybord: 'Ctrl / ⌘ + Q',},{markdown: '```code```',explain: '代码块',keybord: 'Ctrl / ⌘ + Alt + C',},{markdown: '`code`',explain: '行代码块',keybord: 'Ctrl / ⌘ + Alt + K',},{markdown: '省略',explain: '表格',keybord: 'Ctrl / ⌘ + Alt + T',},]return (<Tablecolumns={columns}dataSource={dataSource}pagination={false}size="small"/>)}export default memo(ShortCutKey);
DropDownList
import React from 'react';import { Avatar } from 'antd';import Draft from './Draft'function DropdownList({ avatar }) {const dataSource = (<Menu className="mt-20"><Menu.Item key="0"><a onClick={() => history.push('/write/create')}>写文章</a></Menu.Item><Menu.Item key="1"><Draft /></Menu.Item><Menu.Divider /><Menu.Item key="3"><Link to="/">回到首页</Link></Menu.Item></Menu>)const avatarAttr = {size: 'default',src: avatar,}return (<Dropdown overlay={dataSource} trigger={['click']}><a onClick={e => e.preventDefault()}>用户头像<Avatar {...avatarAttr} icon={<UserOutlined />} /></a></Dropdown>)}export default DropdownList;
Draft

import React, { useState } from 'react';function Draft() {const [visible, setVisible] = useState(false);function onClose() {setVisible(false)}function onClick() {history.push(`/draft/${item.id}`)onClose()}return (<><a onClick={() => setVisible(true)}>草稿箱</a><Drawertitle="草稿箱"onClose={onClose}visible={visible}><ListitemLayout="horizontal"dataSource={[]}renderItem={item => (<List.Item><List.Item.Metatitle={<a onClick={onClick}>{item.title}{item.is_publish ? (<Tag color="success" className="ml-10">已发表</Tag>) : null}</a>}description={`${moment(item.updated_at).format( 'YYYY-MM-DD HH:mm')}`}/></List.Item>)}/></Drawer></>)}export default Draft;
Content
<Row><Col span={12}><MarkdownText /></Col><Col span={12}><MarkdownHtml /></Col></Row>
MarkdownText
import KeyboardEventHandler from 'react-keyboard-event-handler';import UploadImage from './UploadImage';import styled from './index.module.less';function setMarkdown(el, data, start, num) {const { selectionStart, selectionEnd } = elel.focus()el.setSelectionRange(selectionStart + start, selectionStart + start + num)}function MarkdownText() {const handleKey = ['ctrl+b','ctrl+l','ctrl+h','ctrl+alt+t','ctrl+i','ctrl+alt+i','ctrl+alt+c','ctrl+alt+k','ctrl+q',]function onKeyChange(key, e) {const { target, preventDefault } = e;preventDefault();const addHeading = el => {let title = '## 标题'let start = 3if (markdown) {title = '\n## 标题'start = 4}setMarkdown(el, title, start, 2)}const addBold = el => {setMarkdown(el, '**加粗**', 2, 2)}const addItalic = el => {setMarkdown(el, '*斜体*', 1, 2)}const addImage = el => {setMarkdown(el, '', 6, 2)}const addLink = el => {setMarkdown(el, '[描述](链接)', 5, 2)}const addCode = el => {setMarkdown(el, '\n```\n```', 4, 0)}const addLineCode = el => {setMarkdown(el, '``', 1, 0)}const addQuote = el => {setMarkdown(el, '\n> 引用', 3, 2)}const addTable = el => {setMarkdown(el,'\n\n| Col1 | Col2 | Col3 |\n| :----: | :----: | :----: |\n| field1 | field2 | field3 |\n',4,4,)}return {'ctrl+b': addBold(target),'ctrl+h': addHeading(target),'ctrl+l': addLink(target),'ctrl+alt+t': addTable(target),'ctrl+i': addImage(target),'ctrl+q': addQuote(target),'ctrl+alt+i': addItalic(target),'ctrl+alt+c': addCode(target),'ctrl+alt+k': addLineCode(target),}[key];}return (<div className={styled.textareaWrap}><UploadImage /><KeyboardEventHandleronKeyEvent={onKeyChange}handleKeys={handleKey}><TextAreaclassName={styled.textarea}// selectiontext=""placeholder="请输入Markdown"rows={3}onChange={markdownChange}value={markdown}spellCheck="false"autoComplete="off"autoCapitalize="off"autoCorrect="off"autoSize/></KeyboardEventHandler></div>)}export default MarkdownText;
设置 textarea样式, index.module.less
.textareaWrap {min-height: calc(100vh - 56px);overflow-y: auto;border-right: 1px solid #ddd;}.textarea {padding: 16px;outline: none;resize: none;border-color: transparent;min-heihgt: calc(100vh - 60px);scrollbar-width: none;&::-webkit-scrollbar {width: 10px;height: 4px;display: none;}&::-webkit-scrollbar-thumb {border-radius: 4px;background: #cccccc;}&::-webkit-scrollbar-track {border-radius: 0;background: #ffffff;}}
UploadImage
markdown右上角的插入图片
import React, { useState } from 'react';import AliYunOss from '@components/AliyunOss';import { PictureOutlined } from from '@ant-design/icons';function UploadImage() {const [visible, setVisible] = useState(false);const [image, setImage] = useState('');function onClose() {setImage('')setVisible(false)}const returnImage = imageUrl => {setInsertImages([...insertImages, imageUrl])}const returnCoverImageUrl = imageUrl => {setCoverImageUrl(imageUrl)}function inputChange(e) {setImage(e.target.value)}function onOk() {onClose();}return (<><Button type="link" onClick={() => setVisible(true) }><PictureOutlined /></Button><Modaltitle="插入图片"visible={visible}width={560}closable={false}destroyOnClose={true}onCancel={onClose}onOk={onOk}><AliYunOss type="drag" value={image} /><p className="text-center mb8">或</p><Inputplaceholder="输入网络图片地址"size="large"prefix={<PictureOutlined />}value={image}onChange={inputChange}/></Modal></>)}export default UploadImage;
AliYunSso
import React, { useState } from 'react'import { Upload, message } from 'antd'import OSS from 'ali-oss'import { PlusOutlined, LoadingOutlined, InboxOutlined } from '@ant-design/icons'import moment from 'moment'import { accessKeySecret, accessKeyId, bucket } from '@config'const { Dragger } = Uploadconst client = new OSS({region: 'oss-cn-shanghai',accessKeyId,accessKeySecret,bucket,secure: true,})const UploadToOss = (path, file) => {return new Promise((resolve, reject) => {client.put(path, file).then(data => {resolve(data)}).catch(error => {reject(error)})})}const filePath = file => {// 上传文件路径和名称return `${moment().format('YYYYMMDD')}/${file.uid}.${file.type.split('/')[1]}`}function AliYunSso({ type, returnImageUrl }) {const [loading, setLoadding] = useState(false)const [imageUrl, setImageUrl] = useState(null)async function beforeUpload(file) {const imageType = ['image/png', 'image/jpeg', 'image/gif'].includes(file.type);if (!imageType) {message.error('只能上传JPG/PNG格式的图片');return;}const maxSize = file.size / 1024 / 1024 < 4;if (!maxSize) {message.error('图片必须小于4M')}const res = await UploadToOss(filePath(file), file);if (res) {setImageUrl(res.url)returnImageUrl(res.url)}return imageType && maxSize;}const onChange = info => {if (info.file.status === 'uploading') {setLoadding(true)}if (info.file.status === 'done') {console.log(info)}}if (type === 'drag') {return (<Draggername="拖拽上传"onChange={onChange}// multiple= {true}beforeUpload={beforeUpload}><p className="ant-upload-drag-icon"><InboxOutlined /></p><p className="ant-upload-text">点击或者拖拽图片到这个区域</p></Dragger>)}return (<Uploadname="上传图片"listType="picture-card"className="avatar-uploader"style={{ width: 128, height: 128 }}showUploadList={false}beforeUpload={beforeUpload}onChange={onChange}>{imageUrl? <img src={imageUrl} alt="" style={{ width: '100%' }} />: <Button>{loading ? <LoadingOutlined /> : <PlusOutlined />} 上传</Button>}</Upload>)}export default AliYunSso;
MarkdownHtml
import MathJax from 'react-mathjax'import Markdown from '@components/Markdown'function MarkdownHtml() {return (<div style={{ padding: 16 }}><MathJax.Provider input="tex"><Markdown children={markdown} /></MathJax.Provider></div>)}export default MarkdownHtml;
