使用codemirror做一个Markdown编辑器

82 阅读2分钟

给个人博客网站做的一个markdown编辑器,结合minio做了图片存储,使用showdown加highlight.js实现预览效果

 一、展示效果

二、编辑器效果

左边是编辑器 右边是预览效果使用showdown解析highlight.js实现高亮

三、编辑器代码实现

一、引入codemirror

import {useEffect, useRef, forwardRef, useImperativeHandle} from "react";import codemirror from 'codemirror';import "codemirror/lib/codemirror.css";// 主题样式import "codemirror/theme/base16-light.css";import "codemirror/theme/neo.css";// 支持的数据类型import 'codemirror/mode/markdown/markdown';import "codemirror/mode/javascript/javascript";import "codemirror/mode/go/go";import "codemirror/mode/css/css";import "codemirror/mode/yaml/yaml";// 语法检查import 'codemirror/addon/lint/lint';import 'codemirror/addon/lint/lint.css';import 'codemirror/addon/lint/javascript-lint';import 'codemirror/addon/lint/css-lint';import 'codemirror/addon/lint/yaml-lint';// 选中行高亮import "codemirror/addon/selection/active-line";// 调整scrollbar样式功能import 'codemirror/addon/scroll/simplescrollbars.css'import 'codemirror/addon/scroll/simplescrollbars'// 括号、引号编辑和删除时成对出现 括号显示匹配import "codemirror/addon/edit/closebrackets";import "codemirror/addon/edit/matchbrackets";// 代码折叠import 'codemirror/addon/fold/foldgutter.css';import 'codemirror/addon/fold/foldcode';import 'codemirror/addon/fold/foldgutter';import 'codemirror/addon/fold/brace-fold';import 'codemirror/addon/fold/comment-fold';import 'codemirror/addon/fold/markdown-fold';import 'codemirror/addon/fold/indent-fold';// 代码提示import 'codemirror/addon/hint/show-hint.css'; //import 'codemirror/addon/hint/show-hint';import 'codemirror/addon/hint/anyword-hint'; // 简单提示,按需引入,spl可引入sql-hint.js// 搜索功能// find:Ctrl-F (PC), Cmd-F (Mac)// findNext:Ctrl-G (PC), Cmd-G (Mac)// findPrev:Shift-Ctrl-G (PC), Shift-Cmd-G (Mac)// replace:Shift-Ctrl-F (PC), Cmd-Alt-F (Mac)// replaceAll:Shift-Ctrl-R (PC), Shift-Cmd-Alt-F (Mac)import 'codemirror/addon/dialog/dialog.css'import 'codemirror/addon/dialog/dialog'import 'codemirror/addon/search/searchcursor'import 'codemirror/addon/search/search'import 'codemirror/addon/search/jump-to-line'import 'codemirror/addon/search/matchesonscrollbar'import 'codemirror/addon/search/match-highlighter'

二、组件代码实现

/** *   editor.setSize(width,height) 代码框设置宽高 *   editor.setValue(''); 赋值 *   editor.getValue(); 获取值 *   editor.setOption();在其他地方设置新的属性 */function MarkDownEditor(props, ref) {    const {content, onChange, options = {width: 500, height: 500}} = props;    const editorRefEl = useRef(null)    const editorRef = useRef(null)    const ignore = ['', '#', '!', '-', '=', '@', '$', '%', '&', '+', ';', '(', ')', '*'];    useImperativeHandle(ref, () => ({        setValue: (value) => {            editorRef.current.setValue(value)        }    }))    useEffect(() => {        const editor = codemirror.fromTextArea(editorRefEl.current, {            mode: 'text/x-markdown',            theme: "neo", // 主题样式            lint: true, // 开启校验            lineWrapping: true, // 换行和滚动            scrollbarStyle: "overlay", // 滚动条样式 null隐藏滚动条            line: true,            lineNumbers: true, //显示行号            matchBrackets: true, // 括号匹配显示            autoCloseBrackets: true, // 括号输入和退格时成对            extraKeys: {                "Ctrl": "autocomplete"            },            // 代码提示功能            hintOptions: {                // 避免由于提示列表只有一个提示信息时,自动填充                completeSingle: false,                // 不同的语言支持从配置中读取自定义配置 sql语言允许配置表和字段信息,用于代码提示                tables: {                    "table1": ["c1", "c2"],                },            },            indentUnit: 4, // 缩进单位            smartIndent: true, // 自动缩进            // 当前行高亮            styleActiveLine: true, // 当前行高亮            // 在行槽中添加行号显示器、折叠器、语法检测器            gutters: [                "CodeMirror-linenumbers",                "CodeMirror-foldgutter",                // "CodeMirror-lint-markers"            ],            foldGutter: true, // 启用行槽中的代码折叠        })        editorRef.current = editor        editor.setValue(content)        editor.setSize(options.width, options.height)        editor.on('change', (cm) => {            const textValue = cm.getValue() || ''            onChange(textValue)        })        editor.on('keypress', (instance, keyboardEvent) => {            if (!ignore.includes(keyboardEvent.key)) {                instance.showHint();            }        })    }, [])    return <textarea ref={editorRefEl}/>}export default forwardRef(MarkDownEditor)

三、添加预览效果

一、引入

import Showdown from 'showdown'import hljs from 'highlight.js';// import 'highlight.js/styles/docco.css'; // 引入不同的css使用相应的风格// import 'highlight.js/styles/github.css';

二、实现预览效果

export const parseCodeTag = (lang, codeTag) => {    return hljs.highlight(codeTag, {language: lang, ignoreIllegals: false}).value;}export const parseMarkDown = (mkText) => {    let result = ""    if (!mkText) return result;    // 正则表达式,匹配<code />和内容,第一组(.*?) 匹配代码语言  第二组([\s\S]*?) 匹配内容    const reg = /<code class="(.*?)">([\s\S]*?)<\/code>/g;    // const h = /<h([1-9]).*?>([\s\S]*?)<\/h[1-9]>/g;    const h = /<h([1-6]).*?id="([^"]*?)".*?>(.+?)<\/h[1-6]>/g;    const pre = /<pre>/g;    const lt = /&lt;/g;    const gt = /&gt;/g;    const amp = /&amp;/g;    const toc = []    // 增加转换器,    const coverterOptions = {        prefixHeaderId: "pre_", // 设置前缀        simplifiedAutoLink: true, // 将找到的每个有效的URL转为链接        // headerLevelStart: 3,        simpleLineBreaks: true, // 将换行符解析成<br/>, 不再需要两个空格才能换行        splitAdjacentBlockquotes: true, // 相邻的引用快<blockquote />是否可以被分隔        openLinksInNewWindow: true,// 为a标签增加 target="_blank"        extensions: [            // {            //            //     type: 'output',            //     regex: h,            //     replace: (match, level, id, text, index) => {            //         return `<h${level} id="a_${index}">${text}</h${level}>`            //         // return match;            //     }            // },            showdownToc({toc}),            // 背景高亮            {                type: 'output',                regex: pre,                replace: "<pre class='hljs'>"            },            // 代码语法高亮            {                type: 'output',                regex: reg,                replace: (match, lang, code) => {                    code = code.replace(amp, "&").replace(lt, "<").replace(gt, ">")                    return parseCodeTag(lang.split(" ")[0], code)                }            }        ]    }    try {        const showdown = new Showdown.Converter(coverterOptions)        result = showdown.makeHtml(mkText);    } catch (e) {        result = "markdown语法错误:" + e.message    }    return result;}// 将[toc]转成目录function showdownToc({toc}) {    return () => [        {            type: 'output',            filter(source) {                const regex = /(<h([1-6]).*?id="([^"]*?)".*?>(.+?)<\/h[1-6]>)|(<p>\[toc]<\/p>)/g;                // find and collect all headers and [toc] node;                const collection = [];                source.replace(regex, (wholeMatch, _, level, anchor, text) => {                    if (wholeMatch === '<p>[toc]</p>') {                        collection.push({type: 'toc'});                    } else {                        text = text.replace(/<[^>]+>/g, '');                        const tocItem = {                            anchor,                            level: Number(level),                            text,                        };                        if (toc) {                            toc.push(tocItem);                        }                        collection.push({                            type: 'header',                            ...tocItem,                        });                    }                    return '';                });                // calculate toc info                const tocCollection = [];                collection.forEach(({type}, index) => {                    if (type === 'toc') {                        if (collection[index + 1] && collection[index + 1].type === 'header') {                            const headers = [];                            const {level: levelToToc} = collection[index + 1];                            // const levelToToc = 2;                            for (let i = index + 1; i < collection.length; i++) {                                if (collection[i].type === 'toc') break;                                const {level} = collection[i];                                if (level === levelToToc) {                                    headers.push(collection[i]);                                }                            }                            tocCollection.push(headers);                        } else {                            tocCollection.push([]);                        }                    }                });                // replace [toc] node in source                source = source.replace(/<p>\[toc]<\/p>[\n]*/g, () => {                    const headers = tocCollection.shift();                    if (headers && headers.length) {                        return `<div class="entry-nav"><div class="title-nav-ul">${headers                            .map(({                                      text,                                      anchor                                  }, index) => `<a href="#${anchor}" class="title-nav-li ${index ? "" : "active"}">${text}</a>`)                            .join('')}</div></div>\n`;                    }                    return '';                });                return source;            },        },    ];}

四、结合编辑器和预览效果

我这里是增加了后台接口和minio做图片存储这方面的就不粘贴了

一、引入组件和预览函数

import {Dropdown, Form, Menu, message, Modal, Upload} from "antd";import MarkDownEditor from "../../../component/MarkDownEditor";import {    UploadOutlined,    CloseOutlined,    UnorderedListOutlined,    PictureOutlined} from '@ant-design/icons';import {parseMarkDown} from "../../../util/markdown";import {getFileNameExtension} from "../../../util/fileUtil";import style from "./index.module.less"import {useRef, forwardRef, useImperativeHandle, useState} from "react";import UploadImageForm from "./UploadImageForm";

二、组件代码

function ViewEditor(props, ref) {    const {content, onChange, onSave, onGoBack} = props    const [isUploadVisible, setIsUploadVisible] = useState(false);    const [uploadForm] = Form.useForm()    const editRef = useRef(null)    useImperativeHandle(ref, () => ({        setValue: (value) => {            editRef.current.setValue(value)        }    }))    const handleUploadCancel = () => {        uploadForm.resetFields()        setIsUploadVisible(false)    }    const onUploadFinish = (values) => {        const {title, cover, tooltip} = values        let str = `![${title}](${cover} "${tooltip}")`        editRef.current.setValue(content + str)        handleUploadCancel()    }    const onAppendCodeBlock = () => {        editRef.current.setValue(content + "\n```bash\n```")    }    const readLocalFile = content => {        editRef.current.setValue(content + "\n" + content)    }    const menu = (        <Menu            items={[                {                    key: '1',                    label: (                        <span onClick={onAppendCodeBlock}>                                添加代码块                        </span>                    ),                },{                    key: '2',                    label: (                        <span onClick={() => {                            editRef.current.setValue("editRef.current.setValue")                        }}>                                测试                        </span>                    ),                },            ]}        />    );    const uploadProps = {        showUploadList: false,        beforeUpload(file) {            if (getFileNameExtension(file) !== 'md') {                message.error('只支持markdown文件');                return false            }            const reader = new FileReader();            reader.readAsText(file);            reader.onload = () => {                readLocalFile(reader.result)            };            return false        },    };    return <div className={style.editorWrap}>        <div className={style.toolBar}>            <div className={style.toolGroup}>                <PictureOutlined onClick={() => setIsUploadVisible(true)}/>                <Dropdown overlay={menu}>                    <UnorderedListOutlined/>                </Dropdown>                {/*上传本地文件*/}                <Upload {...uploadProps} style={{display: "inline"}}>                    <UploadOutlined style={{fontSize: 18}} title="保存"/>                </Upload>            </div>            <div className={style.toolGroup}>                <UploadOutlined title="保存" onClick={onSave}/>                <CloseOutlined title="返回" onClick={onGoBack}/>            </div>        </div>        <div className={style.editorContain}>            <div className={style.editor}>                <MarkDownEditor                    ref={editRef}                    content={content}                    onChange={onChange}                    options={{                        width: '100%',                        height: '100%'                    }}                />            </div>            <div className={style.editorView}>                <div className="edit-markdown-view markdown-html-content"                     dangerouslySetInnerHTML={{__html: parseMarkDown(content)}}/>            </div>        </div>        <Modal forceRender title="添加图片" visible={isUploadVisible} onOk={uploadForm.submit}               onCancel={handleUploadCancel}>            <UploadImageForm form={uploadForm} onFinish={onUploadFinish}/>        </Modal>    </div>}export default forwardRef(ViewEditor)

五、用法

<ViewEditor            ref={viewEditorRef}            onGoBack={onGoBack}            onSave={showModal}            content={text}            onChange={v => setText(v)}        />