react实现markdown

参考:https://blog.csdn.net/Jack_lzx/article/details/118495763

参考:https://blog.csdn.net/m0_48474585/article/details/119742984

0. 示例

用react实现markdown编辑器

1.基本布局及样式

    <>
      <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;
  }
}

2.编辑区域

  import _ from 'lodash';

  const [content, setContent] = useState('')
  const onEditChange = (e) => { 
    const curContent = e.target.value
    setContent(curContent)
  }

<textarea 
  style={{resize: "none"}} 
  onScroll={(e) => handleScroll(1, e)}
  className='edit' 
  ref={edit}
  onChange={onEditChange}
  ></textarea>
  • 使用textarea实现编辑区域,可以通过下面的css设置去除textarea本身的样式
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;
}
  • 添加onChange方法,监听用户的输入事件,并把用户输入的值(e.target.value)保存到content中

3.展示区域

npm i marked 
npm i highlight.js
  • 安装插件

    • marked用于将content渲染成markdown格式的内容
    • 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。
    });
  }, []);

// 展示
      <div 
		className='show'
		dangerouslySetInnerHTML={{
        __html: marked(content).replace(/<pre>/g, "<pre id='hljs'>"),
      }}></div>
  • 这里(原作者)对样式做了修改,我直接复制过来了(github-dark.css)
#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 {

} */

4.滚动效果

目标:使得左右两边的滚动能实现联动

  .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;
  }
}
  • 上面的css用于设置滚动条的样式,并设置展示区和编辑区超出范围能滚动
// 左边编辑区触发事件
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 的滚动事件,

解决:用一个变量记录手动触发的是那个区域,这个变量有三种状态:没触发(初始状态),触发了左边的编辑区域,触发了右边的预览区域。分为以下两个步骤

  • 1.手动触发某个区域后需要将这个变量设置为对应的值。(手动触发前的变量值必须是初始状态)
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)
    }
}
  • 2.将变量重置为初始状态。这里使用了一个定时器,设定的滚动时间是200ms,滚动结束后将变量初始化。
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)
}

5.工具栏

样式参考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的值
  }
  • 选中的字符串为content.slice(begin, end),在选中的字符串左右拼接上mark.slice(0, mid)和mark.slice(mid)
  • 光标的位置设置为 edit.current.setSelectionRange(begin + mid, end + mid) // 设置光标的位置,可以举个例子:1,(光标开始),2,3,(光标结束),4,5,然后在2,3左右分别加上两个字符,那就变成了1,字符,字符,(光标开始),2,3,(光标结束),字符,字符,4,5。可以看到光标开始的位置变为begin + mid,光标结束的位置也是end + mid

步骤三:还需要实现按键加粗的功能,在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('****')
  }
}

步骤四:找到这种规律之后可以把类似的功能都做了,代码上需要做一些细节上的修改

  • 像加粗,斜体,删除线这种是在选中字符的左右加上标记,像标题,无序,有序这种是在选中字符的左边加上标记,我这里通过OPERATIONTYPE变量进行了区分
  • 鼠标按键需要阻止默认事件
<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方法进行了改造,当需要添加的字符和本身选中字符串左右两边的字符相同时,去除这些字符

  • 这里有个小细节点:当去除字符时的mid值应该取反,因为去除字符后光标会左移
  // 传入增加的元素,操作类型
  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键的缩进

  • 这里我设置了一个常量TABINDENT,用于设置Tab缩进的空格数
  • 并定义了一个获取textarea相关信息的方法getTextareaInfo
  • 当监听到用户按下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
    }
  }
}

优化了一下,如果选中的是多行,应该多行都缩进

  • 修改了getTextareaInfo方法,把结束行的信息也返回了
  • 把之前写在e.key === 'Tab’条件下的内容抽到了一个单独的方法,按下tab时给选中项的每一行都加上了缩进。(关键代码是这个循环:let line = cursorLineIndexStart; line <= cursorLineIndexEnd; line++ )
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时应该删除这一行,我在写代码的时候经常用

  • 编写handleShear方法,把选中的行都删除
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() 
  }

6.添加图片

图片上传部分我参考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

7.其他问题

一:文章内容刷新之后会消失!我使用的是localstroage存储,有更好的方法可以告知我哦

  1. 添加一个暂存按钮
  2. 初始化的时候如果localStorage有存储,则直接把值设置上去
  3. 在发布文章之后记得把localStorage的值清空哈(window.localStorage.removeItem(STAGINGPOST))
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>

二:把编辑器组件抽取成单独的方法,实现内容发布的功能

  • 主要使用了useImperativeHandle和forwardRef
  • 使用forwardRef包裹你写的这个组件,然后把相关的内容暴露出去
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}/>

对了,得把添加图片弹框的逻辑也抽取出来哦~

还会持续优化的哦,后面的内容还在开发学习中…

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值