给个人博客网站做的一个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 = /</g; const gt = />/g; const 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)} />