掘金风格的 markdown编辑器
- 分上下两个模块
- 上面的标题栏和右侧的发布栏
- 内容区域分为左右2个区块
- 左侧为 markdown文本,用 textarea展示
- 右侧为 解析 markdown为 html的预览区
- 核心技术点
- 键盘事件的监听
- makdown格式的解析
解析 markdow用到的 npm
react-keyboard-event-handler
react-markdown
react-mathjax
react-syntax-highlighter
react-zmage
remark-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 />
<Button
loading={loading}
type="primary"
onClick={onSubmit}
>
保存草稿
</Button>
<DropDownList />
</Col>
</Row>
); }
export default Header;
<a name="OgY2v"></a>
### 发布文章
![image.png](https://cdn.nlark.com/yuque/0/2021/png/112859/1625034193249-023aa7d1-38a7-4619-9fa3-0e140366ee40.png#clientId=u7ad0db9d-ec1e-4&from=paste&height=212&id=axDd2&originHeight=636&originWidth=446&originalType=binary&ratio=3&size=185222&status=done&style=none&taskId=ud3505ce7-4104-4a1b-b2c7-789b740d8aa&width=148.66666666666666)
```jsx
function Publish() {
function Content() {
return (
<div>
{
categories?.map(category => (
<CheckableTag
key={category.en_name}
checked={category.id === selectedCategory}
onChange={selected => checkCategorysHandle(category)}
>
{category.name}
</CheckableTag>
))}
<h4>标签</h4>
{
tags?.map(tag => (
<CheckableTag
key={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 (
<Popover
placement="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'
<Popover
placement="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 (
<Table
columns={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>
<Drawer
title="草稿箱"
onClose={onClose}
visible={visible}
>
<List
itemLayout="horizontal"
dataSource={[]}
renderItem={item => (
<List.Item>
<List.Item.Meta
title={
<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 } = el
el.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 = 3
if (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 />
<KeyboardEventHandler
onKeyEvent={onKeyChange}
handleKeys={handleKey}
>
<TextArea
className={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>
<Modal
title="插入图片"
visible={visible}
width={560}
closable={false}
destroyOnClose={true}
onCancel={onClose}
onOk={onOk}
>
<AliYunOss type="drag" value={image} />
<p className="text-center mb8">或</p>
<Input
placeholder="输入网络图片地址"
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 } = Upload
const 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 (
<Dragger
name="拖拽上传"
onChange={onChange}
// multiple= {true}
beforeUpload={beforeUpload}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或者拖拽图片到这个区域</p>
</Dragger>
)
}
return (
<Upload
name="上传图片"
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;