参考:https://blog.csdn.net/Jack_lzx/article/details/118495763
参考:https://blog.csdn.net/m0_48474585/article/details/119742984
用react实现markdown编辑器
<>
<div className='tf_editor_header'>
头部:放一些编辑工具
div>
<div className='tf_editor'>
<div className='edit'>
左边:编辑区域
div>
<div className='show'>
右边:展示区域
div>
div>
>
.tf_editor_header{
height: 60px;
width: 100%;
background-color: #fff;
border-bottom:1px solid rgba(0,0,0,.1);
}
.tf_editor{
display: flex;
flex-direction: row;
height: calc(100vh - 60px);
width: 100%;
.edit{
padding: 0.8rem;
flex: 1;
background-color: #f5f5f5;
max-width: 50vw;
box-sizing: border-box;
border-right: 1px solid rgba(0,0,0,.1);
}
.show{
padding: 0.8rem;
flex: 1;
background-color: #fff;
max-width: 50vw;
box-sizing: border-box;
}
}
import _ from 'lodash';
const [content, setContent] = useState('')
const onEditChange = (e) => {
const curContent = e.target.value
setContent(curContent)
}
textarea {
border: none;
outline: none;
padding: 0;
margin: 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: none;
background-color: transparent;
font-size: inherit;
width: 100%;
}
textarea:focus {
outline: none;
}
npm i marked
npm i highlight.js
安装插件
// 引入
import { marked } from 'marked';
import hljs from 'highlight.js';
import './github-dark.css';
// 配置
useEffect(() => {
// 配置highlight
hljs.configure({
tabReplace: '',
classPrefix: 'hljs-',
languages: ['CSS', 'HTML', 'JavaScript', 'Python', 'TypeScript', 'Markdown'],
});
// 配置marked
marked.setOptions({
renderer: new marked.Renderer(),
highlight: code => hljs.highlightAuto(code).value,
gfm: true, //默认为true。 允许 Git Hub标准的markdown.
tables: true, //默认为true。 允许支持表格语法。该选项要求 gfm 为true。
breaks: true, //默认为false。 允许回车换行。该选项要求 gfm 为true。
});
}, []);
// 展示
/g, ""),
}}>
#hljs {
padding: 12px;
color: #c9d1d9;
background: #0d1117;
border-radius: 12px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.6);
}
code {
font-family: 'FiraCode';
}
/* 代码片段 */
#hljs code {
color: #c9d1d9;
background: #0d1117;
padding: 0;
font-size: 16px;
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
/* prettylights-syntax-keyword */
color: #ff7b72;
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
/* prettylights-syntax-entity */
color: #d2a8ff;
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-variable,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
/* prettylights-syntax-constant */
color: #79c0ff;
}
.hljs-regexp,
.hljs-string,
.hljs-meta .hljs-string {
/* prettylights-syntax-string */
color: #a5d6ff;
}
.hljs-built_in,
.hljs-symbol {
/* prettylights-syntax-variable */
color: #ffa657;
}
.hljs-comment,
.hljs-code,
.hljs-formula {
/* prettylights-syntax-comment */
color: #8b949e;
}
.hljs-name,
.hljs-quote,
.hljs-selector-tag,
.hljs-selector-pseudo {
/* prettylights-syntax-entity-tag */
color: #7ee787;
}
.hljs-subst {
/* prettylights-syntax-storage-modifier-import */
color: #c9d1d9;
}
.hljs-section {
/* prettylights-syntax-markup-heading */
color: #1f6feb;
font-weight: bold;
}
.hljs-bullet {
/* prettylights-syntax-markup-list */
color: #f2cc60;
}
.hljs-emphasis {
/* prettylights-syntax-markup-italic */
color: #c9d1d9;
font-style: italic;
}
.hljs-strong {
/* prettylights-syntax-markup-bold */
color: #c9d1d9;
font-weight: bold;
}
.hljs-addition {
/* prettylights-syntax-markup-inserted */
color: #aff5b4;
background-color: #033a16;
}
.hljs-deletion {
/* prettylights-syntax-markup-deleted */
color: #ffdcd7;
background-color: #67060c;
}
/* .hljs-char.escape_,
.hljs-link,
.hljs-params,
.hljs-property,
.hljs-punctuation,
.hljs-tag {
} */
目标:使得左右两边的滚动能实现联动
.edit{
padding: 0.8rem;
flex: 1;
background-color: #f5f5f5;
max-width: 50vw;
box-sizing: border-box;
border-right: 1px solid rgba(0,0,0,.1);
overflow: scroll; // 编辑区域超出区域滚动
}
.show{
padding: 0.8rem;
flex: 1;
background-color: #fff;
max-width: 50vw;
box-sizing: border-box;
overflow: scroll; // 展示区域超出区域滚动
}
// 设置滚动条的样式
.edit:focus-visible{
outline: 0px solid transparent;
}
.show{
padding: 0.8rem;
flex: 1;
background-color: #fff;
max-width: 50vw;
box-sizing: border-box;
overflow: scroll;
}
// 滚动条的样式
.show::-webkit-scrollbar {
/*滚动条整体样式*/
width : 10px; /*高宽分别对应横竖滚动条的尺寸*/
height: 5px;
}
.show::-webkit-scrollbar-thumb {
/*滚动条里面小方块*/
border-radius: 10px;
background : #ddd;
}
.show::-webkit-scrollbar-track {
/*滚动条里面轨道*/
border-radius: 10px;
background : transparent;
}
.edit::-webkit-scrollbar {
/*滚动条整体样式*/
width : 10px; /*高宽分别对应横竖滚动条的尺寸*/
height: 5px;
}
.edit::-webkit-scrollbar-thumb {
/*滚动条里面小方块*/
border-radius: 10px;
background : #ddd;
}
.edit::-webkit-scrollbar-track {
/*滚动条里面轨道*/
border-radius: 10px;
background : transparent;
}
}
// 左边编辑区触发事件
onScroll={(e) => handleScroll(1, e)}
// 右边编辑区触发事件
onScroll={(e) => handleScroll(2, e)}
const edit = useRef()
const show = useRef()
// 展示区与代码区同步滚动
const handleScroll = (block, event) => {
let { scrollHeight, scrollTop, clientHeight } = event.target
let scale = scrollTop / (scrollHeight - clientHeight)
if(block === 1) {
driveScroll(scale, show.current)
} else if(block === 2) {
driveScroll(scale, edit.current)
}
}
// 驱动一个元素进行滚动
const driveScroll = (scale, el) => {
let { scrollHeight, clientHeight } = el
el.scrollTop = (scrollHeight - clientHeight) * scale // scrollTop的同比例滚动
}
以编辑区域为例,当前滚动的长度可以用scrollTop获取,那他滚动到底部,即scrollTop的最大值为scrollHeight-clientHeight。我们计算 当前滚动值 与 最大滚动值的比例,即
scale = scrollTop / (scrollHeight - clientHeight)
再来计算展示区域,展示区域的当前滚动值 与 最大滚动值的比例(scale)应该与编辑区域的比例相同,那么它当前的滚动长度应该设置为scrollTop = scale * (scrollHeight - clientHeight)
问题:上面的写法会有一个问题,如果你在handleScroll方法中输出block的值,就会发现block的值一直在切换 block 1, block 2, block 1, block 2,block 1, block 2 …
const handleScroll = (block, event) => {
console.log('block', block). // 输出block的值
let { scrollHeight, scrollTop, clientHeight } = event.target
let scale = scrollTop / (scrollHeight - clientHeight)
if(block === 1) {
driveScroll(scale, show.current)
} else if(block === 2) {
driveScroll(scale, edit.current)
}
}
原因:这是因为当你主动触发了block 1的滚动事件,他会令block 2发生滚动,也就是被动触发了block 2 的滚动事件,
解决:用一个变量记录手动触发的是那个区域,这个变量有三种状态:没触发(初始状态),触发了左边的编辑区域,触发了右边的预览区域。分为以下两个步骤
let scrolling = useRef(0) // 记录当前滚动的是哪一个区域,1为编辑区域,2为展示区域
// 展示区与代码区同步滚动
const handleScroll = (block, event) => {
let { scrollHeight, scrollTop, clientHeight } = event.target
let scale = scrollTop / (scrollHeight - clientHeight) // 改进后的计算滚动比例的方法
if(block === 1) {
if(scrolling.current === 0) scrolling.current = 1;
if(scrolling.current === 2) return;
driveScroll(scale, show.current)
} else if(block === 2) {
if(scrolling.current === 0) scrolling.current = 2;
if(scrolling.current === 1) return;
driveScroll(scale, edit.current)
}
}
let scrollTimer = useRef(null) // 记录滚动定时器
// 驱动一个元素进行滚动
const driveScroll = (scale, el) => {
let { scrollHeight, clientHeight } = el
el.scrollTop = (scrollHeight - clientHeight) * scale // scrollTop的同比例滚动
if(scrollTimer.current) clearTimeout(scrollTimer.current);
scrollTimer.current = setTimeout(() => {
scrolling.current = 0
clearTimeout(scrollTimer.current)
}, 200)
}
样式参考csdn的编辑器
目标:实现加粗功能
实现方式:在选中文字的前后加上**
步骤一:为加粗的按钮绑定加粗方法
<div className='operation_item' onClick={() => { addMark('****')}}>
<BoldOutlined />
加粗
</div>
步骤二:光标的位置可以从textarea上读取,textarea自带光标开始和结束的属性:selectionStart,selectionEnd
const addMark = (mark) =>{
const begin = edit.current.selectionStart // 光标开始点
const end = edit.current.selectionEnd // 光标结束
let mid = mark.length / 2 // 比如mark为****,需要在选中字符串前面加上两个**,后面加上两个**
const newValue = (content.slice(0, begin) + mark.slice(0, mid) +
content.slice(begin, end) + mark.slice(mid) + content.slice(end))
edit.current.value = newValue // 设置textarea中的值
edit.current.setSelectionRange(begin + mid, end + mid) // 设置光标的位置
edit.current.focus()
setContent(newValue) // 更新content的值
}
步骤三:还需要实现按键加粗的功能,在textarea上绑定onKeyDown事件,当监听到按下command+b或者contrl+b时候,调用加粗方法
<textarea
style={{resize: "none"}}
onScroll={(e) => handleScroll(1, e)}
className='edit'
ref={edit}
onChange={onEditChange}
onKeyDown={onKeyDown}
></textarea>
const onKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
addMark('****')
}
}
步骤四:找到这种规律之后可以把类似的功能都做了,代码上需要做一些细节上的修改
<div className='operation_item' onClick={() => { addMark('****')}}>
<BoldOutlined />
加粗
</div>
<div className='operation_item' onClick={() => { addMark('**')}}>
<ItalicOutlined />
斜体
</div>
<div className='operation_item' onClick={() => { addMark('# ', OPERATIONTYPE.LEFT)}}>
<BoldOutlined />
标题
</div>
<div className='operation_item' onClick={() => { addMark('~~')}}>
<StrikethroughOutlined />
删除线
</div>
<div className='operation_item' onClick={() => { addMark('- ', OPERATIONTYPE.LEFT)}}>
<UnorderedListOutlined />
无序
</div>
<div className='operation_item'onClick={() => { addMark('1. ', OPERATIONTYPE.LEFT)}}>
<OrderedListOutlined />
有序
</div>
const OPERATIONTYPE = {
BETWEEN: 'between',
LEFT: 'left',
}
const addMark = (mark, type = OPERATIONTYPE.BETWEEN) =>{
const begin = edit.current.selectionStart // 光标开始点
const end = edit.current.selectionEnd // 光标结束
let mid = 0
let newValue = ''
switch(type){
case OPERATIONTYPE.BETWEEN:
mid = mark.length / 2 // 比如mark为****,需要在选中字符串前面加上两个**,后面加上
newValue = (content.slice(0, begin) + mark.slice(0, mid) +
content.slice(begin, end) + mark.slice(mid) + content.slice(end));
break
case OPERATIONTYPE.LEFT:
mid = mark.length
newValue = (content.slice(0, begin) + mark.slice(0, mid) +
content.slice(begin, end) + content.slice(end));
break
}
edit.current.value = newValue // 设置textarea中的值
edit.current.setSelectionRange(begin + mid, end + mid) // 设置光标的位置
edit.current.focus()
setContent(newValue) // 更新content的值
}
const onKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey)) {
e.preventDefault()
switch(e.key){
case 'b':
addMark('****')
break
case 'i':
addMark('**')
break
case '1':
addMark('# ', OPERATIONTYPE.LEFT)
break
case '2':
addMark('## ', OPERATIONTYPE.LEFT)
break
case '3':
addMark('### ', OPERATIONTYPE.LEFT)
break
case '4':
addMark('#### ', OPERATIONTYPE.LEFT)
break
case '5':
addMark('##### ', OPERATIONTYPE.LEFT)
break
case '6':
addMark('###### ', OPERATIONTYPE.LEFT)
break
}
}
}
步骤五:选中文字再次点击应该去除之前添加的字符,这里我对addMark方法进行了改造,当需要添加的字符和本身选中字符串左右两边的字符相同时,去除这些字符
// 传入增加的元素,操作类型
const addMark = (mark, type = OPERATIONTYPE.BETWEEN) =>{
const begin = edit.current.selectionStart // 光标开始点
const end = edit.current.selectionEnd // 光标结束
let mid = 0
let newValue = ''
switch(type){
case OPERATIONTYPE.BETWEEN:
mid = mark.length / 2 // 比如mark为****,需要在选中字符串前面加上两个**,后面加上两个**
// 增加取消的功能
if(content.slice(begin - mid, begin) === mark.slice(0, mid) && content.slice(end , end + mid) === mark.slice(mid)){
newValue = (content.slice(0, begin - mid) +
content.slice(begin, end) + content.slice(end + mid));
mid = - mid
}else{
mid = mark.length / 2 // 比如mark为****,需要在选中字符串前面加上两个**,后面加上两个**
newValue = (content.slice(0, begin) + mark.slice(0, mid) +
content.slice(begin, end) + mark.slice(mid) + content.slice(end));
}
break
case OPERATIONTYPE.LEFT:
mid = mark.length
if(content.slice(begin - mid, begin) === mark){
newValue = (content.slice(0, begin - mid) +
content.slice(begin, end) + content.slice(end));
mid = - mid
}else{
newValue = (content.slice(0, begin) + mark.slice(0, mid) +
content.slice(begin, end) + content.slice(end));
}
break
}
edit.current.value = newValue // 设置textarea中的值
edit.current.setSelectionRange(begin + mid, end + mid) // 设置光标的位置
edit.current.focus()
setContent(newValue) // 更新content的值
}
步骤六:需要处理一下Tab键的缩进
const TABINDENT = 2 // 缩进个数
const getTextareaInfo = (textarea, textContent) => {
let cursorPositionStart = textarea.selectionStart // 光标开始的位置
let cursorPositionEnd = textarea.selectionEnd// 光标开始的位置
let cursorLineIndex = textContent.substring(0, cursorPositionStart).split('\n').length - 1 // 光标所在行的index
let textLineArray = textContent.split('\n') // 将每行切割成数组
let cursorLineContent = textLineArray[cursorLineIndex] // 光标所在行的内容
return {
cursorPositionStart,
cursorPositionEnd,
cursorLineIndex,
textLineArray,
cursorLineContent
}
}
const onKeyDown = (e) => {
// tab键
if(e.key === 'Tab'){
e.preventDefault()
// 需要将光标所在行的前面添加上空格
const {cursorLineIndex, textLineArray, cursorPositionStart, cursorPositionEnd} = getTextareaInfo(edit.current, content)
// 缩进
for(let i = 0; i < TABINDENT;i ++){
textLineArray[cursorLineIndex] = ' ' + textLineArray[cursorLineIndex]
}
const newValue = textLineArray.join('\n')
edit.current.value = newValue // 设置textarea中的值
let len = 0
for(let i = 0; i < cursorLineIndex; i++){
len += textLineArray[i].length
}
edit.current.setSelectionRange(cursorPositionStart + TABINDENT, cursorPositionEnd + TABINDENT) // 设置光标的位置
edit.current.focus()
setContent(newValue) // 更新content的值
}
// 快捷键
if ((e.ctrlKey || e.metaKey)) {
e.preventDefault()
switch(e.key){
case 'b':
addMark('****')
break
case 'i':
addMark('**')
break
case '1':
addMark('# ', OPERATIONTYPE.LEFT)
break
case '2':
addMark('## ', OPERATIONTYPE.LEFT)
break
case '3':
addMark('### ', OPERATIONTYPE.LEFT)
break
case '4':
addMark('#### ', OPERATIONTYPE.LEFT)
break
case '5':
addMark('##### ', OPERATIONTYPE.LEFT)
break
case '6':
addMark('###### ', OPERATIONTYPE.LEFT)
break
}
}
}
优化了一下,如果选中的是多行,应该多行都缩进
const getTextareaInfo = (textarea, textContent) => {
let cursorPositionStart = textarea.selectionStart // 光标开始的位置
let cursorPositionEnd = textarea.selectionEnd// 光标开始的位置
let cursorLineIndexStart = textContent.substring(0, cursorPositionStart).split('\n').length - 1 // 光标开始行的index
let cursorLineIndexEnd = textContent.substring(0, cursorPositionEnd).split('\n').length - 1 // 光标开始行的index
let textLineArray = textContent.split('\n') // 将每行切割成数组
return {
cursorPositionStart,
cursorPositionEnd,
cursorLineIndexStart,
cursorLineIndexEnd,
textLineArray,
}
}
const handleTab = () => {
// 需要将光标所在行的前面添加上空格
const {cursorLineIndexStart, cursorLineIndexEnd, textLineArray, cursorPositionStart, cursorPositionEnd} = getTextareaInfo(edit.current, content)
// 缩进
for(let line = cursorLineIndexStart; line <= cursorLineIndexEnd; line++ ){
for(let i = 0; i < TABINDENT;i ++){
textLineArray[line] = ' ' + textLineArray[line]
}
}
const newValue = textLineArray.join('\n')
edit.current.value = newValue // 设置textarea中的值
let len = 0
for(let i = 0; i < cursorLineIndexStart; i++){
len += textLineArray[i].length
}
edit.current.setSelectionRange(cursorPositionStart + TABINDENT, cursorPositionEnd + (cursorLineIndexEnd - cursorLineIndexStart + 1) * TABINDENT) // 设置光标
edit.current.focus()
setContent(newValue) // 更新content的值
}
步骤七:突然想起来一个功能:按下command/contrl+x时应该删除这一行,我在写代码的时候经常用
const handleShear = () => {
// 需要将光标所在行的前面添加上空格
const {cursorLineIndexStart, cursorLineIndexEnd, textLineArray, cursorPositionStart} = getTextareaInfo(edit.current, content)
const newTextLineArray = textLineArray.slice(0, cursorLineIndexStart).concat(textLineArray.slice(cursorLineIndexEnd + 1))
const newValue = newTextLineArray.join('\n')
edit.current.value = newValue // 设置textarea中的值
let len = 0
for(let i = 0; i < cursorLineIndexStart; i++){
len += textLineArray[i].length
}
edit.current.setSelectionRange(cursorPositionStart, cursorPositionStart) // 设置光标的位置
edit.current.focus()
setContent(newValue) // 更新content的值
}
既然实现了剪切,那肯定要粘贴(在handleShear方法复制即可)
// 安装:
npm i --save copy-to-clipboard
// 使用:
copy(textLineArray.slice(cursorLineIndexStart, cursorLineIndexEnd).join('\n'));
那顺便实现一下复制粘贴的功能吧
// 复制方法command/contrl+c
const handleCopy = () => {
// 需要将光标所在行的前面添加上空格
const {cursorLineIndexStart, cursorLineIndexEnd, textLineArray} = getTextareaInfo(edit.current, content)
copy(textLineArray.slice(cursorLineIndexStart, cursorLineIndexEnd).join('\n'));
}
// 粘贴方法command/contrl+v
const handlePaste = async () => {
const coptText = await navigator.clipboard.readText()
// 需要将光标所在行的前面添加上空格
const {cursorPositionStart, cursorPositionEnd} = getTextareaInfo(edit.current, content)
const newValue = content.slice(0, cursorPositionStart) + coptText + content.slice(cursorPositionEnd)
edit.current.value = newValue // 设置textarea中的值
edit.current.setSelectionRange(cursorPositionStart, cursorPositionStart + coptText.length) // 设置光标的位置
edit.current.focus()
setContent(newValue) // 更新content的值
}
对了,还有回车,如果上面是列表,回车的时候也需要是列表
const handleEnter = (e) => {
// 其实只要上一行有没有-开头或者1.开头就好了
// 需要将光标所在行的前面添加上空格
const {cursorLineIndexStart, cursorPositionEnd, textLineArray, cursorPositionStart} = getTextareaInfo(edit.current, content)
const preLine = textLineArray[cursorLineIndexStart]
console.log(preLine)
if(preLine.indexOf('- ') === 0){
e.preventDefault()
const newValue = edit.current.value + '\n- '
edit.current.value = newValue// 设置textarea中的值
edit.current.setSelectionRange(cursorPositionEnd + 3, cursorPositionEnd + 3) // 设置光标的位置
edit.current.focus()
setContent(newValue) // 更新content的值
}else if(/^\d+\. /.test(preLine)){
const num = preLine.match(/^\d+/)[0]
e.preventDefault()
const newValue = edit.current.value + `\n${Number(num)+1}. `
edit.current.value = newValue// 设置textarea中的值
edit.current.setSelectionRange(cursorPositionEnd + 3 + num.length, cursorPositionEnd + 3 + num.length) // 设置光标的位置
edit.current.focus()
setContent(newValue) // 更新content的值
}
}
忘了还有全选!(command/contrl+a)
const handleSelectAll = () => {
// 需要将光标所在行的前面添加上空格
edit.current.setSelectionRange(0, content.length) // 设置光标的位置
edit.current.focus()
}
图片上传部分我参考csdn,做了一个弹窗,分上传图片tab和添加链接tab,弹窗部分自由实现呀~
弹窗的功能主要是点击确定后把上传图片的链接 或 添加的链接抛出来,然后拼接到内容上就行
const [addImgModalVisible, setAddImgModalVisible] = useState(false)
<AddImageModal visible={addImgModalVisible} setVisible={setAddImgModalVisible} saveUrl={saveUrl}/>
const saveUrl = (url, callback) => {
const {cursorPositionStart, cursorPositionEnd} = getTextareaInfo(edit.current, content)
const newValue = content.slice(0, cursorPositionStart) + `![在这里插入图片描述](${url})` + content.slice(cursorPositionEnd)
edit.current.value = newValue// 设置textarea中的值
edit.current.setSelectionRange(cursorPositionEnd + 13, cursorPositionEnd + 13 + url.length) // 设置光标的位置
edit.current.focus()
setContent(newValue) // 更新content的值
// 把url的地址拼接到开始光标的位置即可
callback && callback()
}11
一:文章内容刷新之后会消失!我使用的是localstroage存储,有更好的方法可以告知我哦
const STAGINGPOST = "stagingpost"
useEffect(() => {
let stagingpost = window.localStorage.getItem(STAGINGPOST)
if(stagingpost){
edit.current.value = stagingpost// 设置textarea中的值
setContent(stagingpost) // 更新content的值
}
},[])
<Button onClick={() => {
window.localStorage.setItem(STAGINGPOST, content)
message.success('暂存成功')
}}>暂存</Button>
二:把编辑器组件抽取成单独的方法,实现内容发布的功能
import {useImperativeHandle, forwardRef } from 'react';
const Editor = forwardRef((props, ref) => {
...
useImperativeHandle(ref, () => (
{
edit,
show,
content,
setContent
}
))
...
}
const EditorRef = useRef()
// 调用的话使用这种写法:EditorRef.current.edit/EditorRef.current.show/EditorRef.current.content/EditorRef.current.setContent
<Editor
ref={EditorRef}
extraOperation={extraOperation}/>
对了,得把添加图片弹框的逻辑也抽取出来哦~
还会持续优化的哦,后面的内容还在开发学习中…