掘金风格的 markdown编辑器

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

markdown编辑器.jpg

解析 markdow用到的 npm

  1. react-keyboard-event-handler
  2. react-markdown
  3. react-mathjax
  4. react-syntax-highlighter
  5. react-zmage
  6. 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 (

  1. <Col span={6}>
  2. <Publish />
  3. <Button
  4. loading={loading}
  5. type="primary"
  6. onClick={onSubmit}
  7. >
  8. 保存草稿
  9. </Button>
  10. <DropDownList />
  11. </Col>
  12. </Row>

); }

export default Header;

  1. <a name="OgY2v"></a>
  2. ### 发布文章
  3. ![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)
  4. ```jsx
  5. function Publish() {
  6. function Content() {
  7. return (
  8. <div>
  9. {
  10. categories?.map(category => (
  11. <CheckableTag
  12. key={category.en_name}
  13. checked={category.id === selectedCategory}
  14. onChange={selected => checkCategorysHandle(category)}
  15. >
  16. {category.name}
  17. </CheckableTag>
  18. ))}
  19. <h4>标签</h4>
  20. {
  21. tags?.map(tag => (
  22. <CheckableTag
  23. key={tag.en_name}
  24. checked={tag.id === selectedTag}
  25. onChange={() => checkTagHandle(tag)}
  26. >
  27. {tag.name}
  28. </CheckableTag>
  29. ))}
  30. <h4>文章封面图</h4>
  31. <AliOssUpload type="click" returnImageUrl={returnCoverImageUrl} />
  32. <Button type="primary" onClick={onPublish}>
  33. 发布文章
  34. </Button>
  35. </div>
  36. )
  37. }
  38. return (
  39. <Popover
  40. placement="bottom"
  41. title={发布文章}
  42. overlayStyle={{ width: 300 }}
  43. trigger="click"
  44. content={Content()}
  45. >
  46. <Button type="link">
  47. <CaretDownOutlined /> 发布
  48. </Button>
  49. </Popover>
  50. )
  51. }
  52. export default Publish;

markdown语法提示

image.png

  1. import { QuestionCircleOutlined } from '@ant-design/icons'
  2. <Popover
  3. placement="bottom"
  4. title="快捷键"
  5. overlayStyle={{ width: 400 }}
  6. content={<ShortCutKey />}
  7. >
  8. <QuestionCircleOutlined />
  9. </Popover>
  10. function ShortCutKey() {
  11. const columns = [
  12. {
  13. title: 'Markdown',
  14. dataIndex: 'markdown',
  15. key: 'markdown',
  16. },
  17. {
  18. title: '说明',
  19. dataIndex: 'explain',
  20. key: 'explain',
  21. },
  22. {
  23. title: '快捷键',
  24. dataIndex: 'keybord',
  25. key: 'keybord',
  26. },
  27. ]
  28. const dataSource = [
  29. {
  30. markdown: '## 标题',
  31. explain: 'H2',
  32. keybord: 'Ctrl / ⌘ + H',
  33. },
  34. {
  35. markdown: '**文本**',
  36. explain: '加粗',
  37. keybord: 'Ctrl / ⌘ + B',
  38. },
  39. {
  40. markdown: '*文本*',
  41. explain: '斜体',
  42. keybord: 'Ctrl / ⌘ + Alt + I',
  43. },
  44. {
  45. markdown: '[描述](链接)',
  46. explain: '链接',
  47. keybord: 'Ctrl / ⌘ + L',
  48. },
  49. {
  50. markdown: '![描述](链接)',
  51. explain: '插入图片',
  52. keybord: 'Ctrl / ⌘ + I',
  53. },
  54. {
  55. markdown: '> 引用',
  56. explain: '引用',
  57. keybord: 'Ctrl / ⌘ + Q',
  58. },
  59. {
  60. markdown: '```code```',
  61. explain: '代码块',
  62. keybord: 'Ctrl / ⌘ + Alt + C',
  63. },
  64. {
  65. markdown: '`code`',
  66. explain: '行代码块',
  67. keybord: 'Ctrl / ⌘ + Alt + K',
  68. },
  69. {
  70. markdown: '省略',
  71. explain: '表格',
  72. keybord: 'Ctrl / ⌘ + Alt + T',
  73. },
  74. ]
  75. return (
  76. <Table
  77. columns={columns}
  78. dataSource={dataSource}
  79. pagination={false}
  80. size="small"
  81. />
  82. )
  83. }
  84. export default memo(ShortCutKey);

DropDownList

  1. import React from 'react';
  2. import { Avatar } from 'antd';
  3. import Draft from './Draft'
  4. function DropdownList({ avatar }) {
  5. const dataSource = (
  6. <Menu className="mt-20">
  7. <Menu.Item key="0">
  8. <a onClick={() => history.push('/write/create')}>写文章</a>
  9. </Menu.Item>
  10. <Menu.Item key="1">
  11. <Draft />
  12. </Menu.Item>
  13. <Menu.Divider />
  14. <Menu.Item key="3">
  15. <Link to="/">回到首页</Link>
  16. </Menu.Item>
  17. </Menu>
  18. )
  19. const avatarAttr = {
  20. size: 'default',
  21. src: avatar,
  22. }
  23. return (
  24. <Dropdown overlay={dataSource} trigger={['click']}>
  25. <a onClick={e => e.preventDefault()}>
  26. 用户头像
  27. <Avatar {...avatarAttr} icon={<UserOutlined />} />
  28. </a>
  29. </Dropdown>
  30. )
  31. }
  32. export default DropdownList;

Draft

image.png

  1. import React, { useState } from 'react';
  2. function Draft() {
  3. const [visible, setVisible] = useState(false);
  4. function onClose() {
  5. setVisible(false)
  6. }
  7. function onClick() {
  8. history.push(`/draft/${item.id}`)
  9. onClose()
  10. }
  11. return (
  12. <>
  13. <a onClick={() => setVisible(true)}>草稿箱</a>
  14. <Drawer
  15. title="草稿箱"
  16. onClose={onClose}
  17. visible={visible}
  18. >
  19. <List
  20. itemLayout="horizontal"
  21. dataSource={[]}
  22. renderItem={item => (
  23. <List.Item>
  24. <List.Item.Meta
  25. title={
  26. <a onClick={onClick}>
  27. {item.title}
  28. {item.is_publish ? (
  29. <Tag color="success" className="ml-10">
  30. 已发表
  31. </Tag>
  32. ) : null}
  33. </a>
  34. }
  35. description={`${moment(item.updated_at).format( 'YYYY-MM-DD HH:mm')}`}
  36. />
  37. </List.Item>
  38. )}
  39. />
  40. </Drawer>
  41. </>
  42. )
  43. }
  44. export default Draft;

Content

  1. <Row>
  2. <Col span={12}>
  3. <MarkdownText />
  4. </Col>
  5. <Col span={12}>
  6. <MarkdownHtml />
  7. </Col>
  8. </Row>

MarkdownText

  1. import KeyboardEventHandler from 'react-keyboard-event-handler';
  2. import UploadImage from './UploadImage';
  3. import styled from './index.module.less';
  4. function setMarkdown(el, data, start, num) {
  5. const { selectionStart, selectionEnd } = el
  6. el.focus()
  7. el.setSelectionRange(selectionStart + start, selectionStart + start + num)
  8. }
  9. function MarkdownText() {
  10. const handleKey = [
  11. 'ctrl+b',
  12. 'ctrl+l',
  13. 'ctrl+h',
  14. 'ctrl+alt+t',
  15. 'ctrl+i',
  16. 'ctrl+alt+i',
  17. 'ctrl+alt+c',
  18. 'ctrl+alt+k',
  19. 'ctrl+q',
  20. ]
  21. function onKeyChange(key, e) {
  22. const { target, preventDefault } = e;
  23. preventDefault();
  24. const addHeading = el => {
  25. let title = '## 标题'
  26. let start = 3
  27. if (markdown) {
  28. title = '\n## 标题'
  29. start = 4
  30. }
  31. setMarkdown(el, title, start, 2)
  32. }
  33. const addBold = el => {
  34. setMarkdown(el, '**加粗**', 2, 2)
  35. }
  36. const addItalic = el => {
  37. setMarkdown(el, '*斜体*', 1, 2)
  38. }
  39. const addImage = el => {
  40. setMarkdown(el, '![描述](链接)', 6, 2)
  41. }
  42. const addLink = el => {
  43. setMarkdown(el, '[描述](链接)', 5, 2)
  44. }
  45. const addCode = el => {
  46. setMarkdown(el, '\n```\n```', 4, 0)
  47. }
  48. const addLineCode = el => {
  49. setMarkdown(el, '``', 1, 0)
  50. }
  51. const addQuote = el => {
  52. setMarkdown(el, '\n> 引用', 3, 2)
  53. }
  54. const addTable = el => {
  55. setMarkdown(
  56. el,
  57. '\n\n| Col1 | Col2 | Col3 |\n| :----: | :----: | :----: |\n| field1 | field2 | field3 |\n',
  58. 4,
  59. 4,
  60. )
  61. }
  62. return {
  63. 'ctrl+b': addBold(target),
  64. 'ctrl+h': addHeading(target),
  65. 'ctrl+l': addLink(target),
  66. 'ctrl+alt+t': addTable(target),
  67. 'ctrl+i': addImage(target),
  68. 'ctrl+q': addQuote(target),
  69. 'ctrl+alt+i': addItalic(target),
  70. 'ctrl+alt+c': addCode(target),
  71. 'ctrl+alt+k': addLineCode(target),
  72. }[key];
  73. }
  74. return (
  75. <div className={styled.textareaWrap}>
  76. <UploadImage />
  77. <KeyboardEventHandler
  78. onKeyEvent={onKeyChange}
  79. handleKeys={handleKey}
  80. >
  81. <TextArea
  82. className={styled.textarea}
  83. // selectiontext=""
  84. placeholder="请输入Markdown"
  85. rows={3}
  86. onChange={markdownChange}
  87. value={markdown}
  88. spellCheck="false"
  89. autoComplete="off"
  90. autoCapitalize="off"
  91. autoCorrect="off"
  92. autoSize
  93. />
  94. </KeyboardEventHandler>
  95. </div>
  96. )
  97. }
  98. export default MarkdownText;

设置 textarea样式, index.module.less

  1. .textareaWrap {
  2. min-height: calc(100vh - 56px);
  3. overflow-y: auto;
  4. border-right: 1px solid #ddd;
  5. }
  6. .textarea {
  7. padding: 16px;
  8. outline: none;
  9. resize: none;
  10. border-color: transparent;
  11. min-heihgt: calc(100vh - 60px);
  12. scrollbar-width: none;
  13. &::-webkit-scrollbar {
  14. width: 10px;
  15. height: 4px;
  16. display: none;
  17. }
  18. &::-webkit-scrollbar-thumb {
  19. border-radius: 4px;
  20. background: #cccccc;
  21. }
  22. &::-webkit-scrollbar-track {
  23. border-radius: 0;
  24. background: #ffffff;
  25. }
  26. }

UploadImage

markdown右上角的插入图片
image.png

  1. import React, { useState } from 'react';
  2. import AliYunOss from '@components/AliyunOss';
  3. import { PictureOutlined } from from '@ant-design/icons';
  4. function UploadImage() {
  5. const [visible, setVisible] = useState(false);
  6. const [image, setImage] = useState('');
  7. function onClose() {
  8. setImage('')
  9. setVisible(false)
  10. }
  11. const returnImage = imageUrl => {
  12. setInsertImages([...insertImages, imageUrl])
  13. }
  14. const returnCoverImageUrl = imageUrl => {
  15. setCoverImageUrl(imageUrl)
  16. }
  17. function inputChange(e) {
  18. setImage(e.target.value)
  19. }
  20. function onOk() {
  21. onClose();
  22. }
  23. return (
  24. <>
  25. <Button type="link" onClick={() => setVisible(true) }>
  26. <PictureOutlined />
  27. </Button>
  28. <Modal
  29. title="插入图片"
  30. visible={visible}
  31. width={560}
  32. closable={false}
  33. destroyOnClose={true}
  34. onCancel={onClose}
  35. onOk={onOk}
  36. >
  37. <AliYunOss type="drag" value={image} />
  38. <p className="text-center mb8">或</p>
  39. <Input
  40. placeholder="输入网络图片地址"
  41. size="large"
  42. prefix={<PictureOutlined />}
  43. value={image}
  44. onChange={inputChange}
  45. />
  46. </Modal>
  47. </>
  48. )
  49. }
  50. export default UploadImage;

AliYunSso

  1. import React, { useState } from 'react'
  2. import { Upload, message } from 'antd'
  3. import OSS from 'ali-oss'
  4. import { PlusOutlined, LoadingOutlined, InboxOutlined } from '@ant-design/icons'
  5. import moment from 'moment'
  6. import { accessKeySecret, accessKeyId, bucket } from '@config'
  7. const { Dragger } = Upload
  8. const client = new OSS({
  9. region: 'oss-cn-shanghai',
  10. accessKeyId,
  11. accessKeySecret,
  12. bucket,
  13. secure: true,
  14. })
  15. const UploadToOss = (path, file) => {
  16. return new Promise((resolve, reject) => {
  17. client
  18. .put(path, file)
  19. .then(data => {
  20. resolve(data)
  21. })
  22. .catch(error => {
  23. reject(error)
  24. })
  25. })
  26. }
  27. const filePath = file => {
  28. // 上传文件路径和名称
  29. return `${moment().format('YYYYMMDD')}/${file.uid}.${file.type.split('/')[1]}`
  30. }
  31. function AliYunSso({ type, returnImageUrl }) {
  32. const [loading, setLoadding] = useState(false)
  33. const [imageUrl, setImageUrl] = useState(null)
  34. async function beforeUpload(file) {
  35. const imageType = ['image/png', 'image/jpeg', 'image/gif'].includes(file.type);
  36. if (!imageType) {
  37. message.error('只能上传JPG/PNG格式的图片');
  38. return;
  39. }
  40. const maxSize = file.size / 1024 / 1024 < 4;
  41. if (!maxSize) {
  42. message.error('图片必须小于4M')
  43. }
  44. const res = await UploadToOss(filePath(file), file);
  45. if (res) {
  46. setImageUrl(res.url)
  47. returnImageUrl(res.url)
  48. }
  49. return imageType && maxSize;
  50. }
  51. const onChange = info => {
  52. if (info.file.status === 'uploading') {
  53. setLoadding(true)
  54. }
  55. if (info.file.status === 'done') {
  56. console.log(info)
  57. }
  58. }
  59. if (type === 'drag') {
  60. return (
  61. <Dragger
  62. name="拖拽上传"
  63. onChange={onChange}
  64. // multiple= {true}
  65. beforeUpload={beforeUpload}
  66. >
  67. <p className="ant-upload-drag-icon">
  68. <InboxOutlined />
  69. </p>
  70. <p className="ant-upload-text">点击或者拖拽图片到这个区域</p>
  71. </Dragger>
  72. )
  73. }
  74. return (
  75. <Upload
  76. name="上传图片"
  77. listType="picture-card"
  78. className="avatar-uploader"
  79. style={{ width: 128, height: 128 }}
  80. showUploadList={false}
  81. beforeUpload={beforeUpload}
  82. onChange={onChange}
  83. >
  84. {
  85. imageUrl
  86. ? <img src={imageUrl} alt="" style={{ width: '100%' }} />
  87. : <Button>{loading ? <LoadingOutlined /> : <PlusOutlined />} 上传</Button>
  88. }
  89. </Upload>
  90. )
  91. }
  92. export default AliYunSso;

MarkdownHtml

  1. import MathJax from 'react-mathjax'
  2. import Markdown from '@components/Markdown'
  3. function MarkdownHtml() {
  4. return (
  5. <div style={{ padding: 16 }}>
  6. <MathJax.Provider input="tex">
  7. <Markdown children={markdown} />
  8. </MathJax.Provider>
  9. </div>
  10. )
  11. }
  12. export default MarkdownHtml;