手写一个富文本编辑器

简易富文本

概述

其实想要实现富文本很容易
简易的富文本主要功能一共两部分

  • contenteditable=‘true’
  • document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

核心功能

contenteditable

contenteditable='true’是将div设置成可编辑的模式

<div contenteditable="true" />

execCommand()

execCommand是执行相关的富文本操作,比如加粗,斜体,有序列表等等,执行后会自动为光标所在行或者选中range添加相关html标签或属性

  • 第一个参数为相关指令,项目中用到的常见指令为
指令用途
bold加粗
italic斜体
underline下划线
justifyleft justifyright justifycenter左右对齐,居中
undo redo撤销,重做
insertUnorderedList/insertOrderedList无序列表,有序列表
  • 第二个参数为是否展示用户界面,一般为 false
  • 第三个参数为一些命令(例如 insertImage)需要额外的参数(insertImage 需要提供插入 image 的 url),默认为 null。

好的,大功告成!

好的不开玩笑,写的过程中一些功能的实现还是踩了坑的

功能踩坑

showHTML格式

document.execCommand()指令执行时是将光标所在行或者所选范围包裹成相关标签,因此存入数据库中的数据其实是html格式。如果需要在富文本中查看源代码,则需要进行转换。

保存光标

在执行document.execCommand()的过程中,始终需要明确光标或者range的位置,但是某些功能比如插入图片,插入url,弹出的dialog会使富文本失去他的光标,因此我的思路是在onBlur时保存range,然后在执行插入命令前恢复range.

具体由Range和Selection相关的api实现

保存range
    const saveSelection=()=> {
        if (window.getSelection) {
            let sel = window.getSelection();
            if (sel.getRangeAt && sel.rangeCount) {
                return sel.getRangeAt(0);
            }
        }
        return null;
    }
恢复range
    const restoreSelection=(range)=> {
        if (range) {
            if (window.getSelection) {
                let sel = window.getSelection();
                sel.removeAllRanges();
                sel.addRange(range);
            }
        }
    }

环境配置

vue3.2 setup语法糖
img图片另行下载

未完成

  • 添加代码块
  • insert图片拖拽调整大小
  • 未完待续

源码

<!-- RichEditor.vue -->
<template>
  <div class="editor-container">
    <div class="editor-header">
      <!-- normal setting -->
      <!-- <button  @click="applyCommand('bold')" >
       <i style="width:20px; height:20px" class="svg-icon-rich-bold"></i>
      </button> -->
      <img @click="applyCommand('bold')" :src="baseUrl+'boldIcon.svg'" alt="bold" title="Bold"/>
      <img @click="applyCommand('italic')" :src="baseUrl+'italicIcon.svg'" alt="italic" title="Italic"/>
      <img @click="applyCommand('underline')" :src="baseUrl+'underlineIcon.svg'" alt="underline" title="Underline"/>
      <img @click="applyCommand('justifyleft')" :src="baseUrl+'alignleftIcon.svg'" alt="align left" title="align Left"/>
      <img @click="applyCommand('justifycenter')" :src="baseUrl+'aligncenterIcon.svg'" alt="align Center" title="align Center"/>
      <img @click="applyCommand('justifyright')" :src="baseUrl+'alignrightIcon.svg'" alt="align Right" title="align Right"/>
      <img @click="applyCommand('undo')" :src="baseUrl+'undoIcon.svg'" alt="undo" title="Undo"/>
      <img @click="applyCommand('redo')" :src="baseUrl+'redoIcon.svg'" alt="redo" title="Redo"/>
      <img @click="applyCommand('insertUnorderedList')" :src="baseUrl+'ulIcon.svg'" alt="ulList" title="ul List"/>
      <img @click="applyCommand('insertOrderedList')" :src="baseUrl+'olIcon.svg'" alt="olList" title="ol List"/>
      <img @click="linkDialogVisible = true" :src="baseUrl+'linkIcon.svg'" alt="link" title="add link"/>
      <img @click="dialogVisible = true" :src="baseUrl+'imgIcon.svg'" alt="insert Img" title="insert Img"/>
      <img @click="setDocMode(!showHTML);" :src="baseUrl+'htmlIcon.svg'" alt="HTML mode" title="HTML mode" ref="showHTMLRef"/>

      <!-- <img @click="saveContent" :src="saveIcon" alt="save" title="save" style="float: right;"/> -->
      
      <!-- multiple selection setting -->
      <div>
        <!-- <el-select v-model="headValue" class="m-2 header-setting" placeholder="select a format option" value-key="headValue" @change="applyCommand('formatBlock',headValue)">
          <el-option
            v-for="item in headOptions"
            :key="item.value"
            :label="item.label"
            :value="item.value"
          />
        </el-select> -->
        <!-- heading setting -->
        <select v-model="headValue" class="m-2 header-setting" placeholder="select a format option" @change="setHeading($event)">
          <!-- <option selected>formatting</option> -->
          <option v-for="(option,id) in headOptions" :key="id" :value="option.label">
            {{option.label}}
          </option>
        </select>
        <!-- fontsize setting -->
        <select v-model="fontsizeValue" class="m-2 header-setting" placeholder="select a format option" @change="setfontsize($event)">
          <option v-for="(option,id) in fontsizeOptions" :key="id" :value="option.label">
            {{option.label}}
          </option>
        </select>
        <!-- font setting -->
        <select v-model="fontValue" class="m-2 header-setting" placeholder="select a format option" @change="setfont($event)">
          <option v-for="(option,id) in fontOptions" :key="id" :value="option.label">
            {{option.label}}
          </option>
        </select>
        <el-tooltip placement="bottom">
          <template #content>background color</template>
          <span>
            <el-color-picker v-model="bgcColor" @change="changeBgcColor"/>
          </span>
        </el-tooltip>
        <el-tooltip placement="bottom">
          <template #content>font color</template>
          <span>
            <el-color-picker v-model="fontColor" @change="changeFontColor"/>
          </span>
        </el-tooltip>
      </div>
      <!-- image setting dialog -->
      <el-dialog
        v-model="dialogVisible"
        title="insert image"
        width="30%"
      >
        <el-tabs v-model="activeName" class="demo-tabs" @tab-click="handleClick">
          <el-tab-pane label="Link" name="first">
            <el-input v-model="inputSrc" placeholder="Please input the img link" />
          </el-tab-pane>
          <el-tab-pane label="File" name="second">
                <el-upload
                  ref="uploadFile"
                  action='#'
                  :http-request="imgToBase"
                  :on-exceed="handleExceed"
                  :limit="1"
                  :show-file-list="false"
                  :auto-upload="true"
                  :before-upload="beforeUpload" class="upload-button">
                  <img v-if="state.bannerUrl" :src="state.bannerUrl" class="avatar" />
                  <el-button type="primary" size="mini">Select Image</el-button>
              </el-upload>
          </el-tab-pane>
        </el-tabs>
        <template #footer>
          <span class="dialog-footer">
            <el-button @click="dialogVisible = false">Cancel</el-button>
            <el-button type="primary" @click="insertImg"
              >Confirm</el-button
            >
          </span>
        </template>
      </el-dialog>
      <!-- link dialog -->
      <el-dialog
        v-model="linkDialogVisible"
        title="insert link"
        width="30%"
      >
        <el-input v-model="inputURL" placeholder="Please input the url link" />
        <el-input v-model="inputURLTitle" placeholder="Please input the url title" />
        <template #footer>
          <span class="dialog-footer">
            <el-button @click="linkDialogVisible = false">Cancel</el-button>
            <el-button type="primary" @click="setLink"
              >Confirm</el-button
            >
          </span>
        </template>
      </el-dialog>
    </div>
    <div
      id="textBox"
      ref="TextRef"
      v-html="innerValue"
      contenteditable="true"
      class="editor-content"
      @blur="saveCursors"
    />
  </div>
</template>

<script lang="ts" setup>
    import { defineProps, onMounted,reactive,ref,defineEmits } from 'vue'
    import { selectKey, TabsPaneContext } from 'element-plus'
    import { ElMessage } from 'element-plus';
    
    const baseUrl = process.env.BASE_URL+'Vue3-WebApplication/dist/img/richEditor/';
    const props = defineProps(['value'])
    let innerValue = props.value || '<p><br></p>'
    let showHTML = ref(false);
    let showHTMLRef = ref(null);
    let oDoc, sDefTxt;
    onMounted(()=>{
        document.execCommand('defaultParagraphSeparator', false, 'p');
    })
    // normal setting
    const applyCommand=(cmd,value=null)=>{
      // restoreCursor();
      document.execCommand(cmd,false,value);
    }
    const setDocMode=(bToSource)=> {
      oDoc = TextRef.value;
      sDefTxt = oDoc.innerHTML;
      var oContent;
      if (bToSource) {
        oContent = document.createTextNode(oDoc.innerHTML);
        oDoc.innerHTML = "";
        var oPre = document.createElement("div");
        oDoc.contentEditable = "false";
        oPre.id = "sourceText";
        oPre.contentEditable = "true";
        oPre.appendChild(oContent);
        oDoc.appendChild(oPre);
      } else {
        if (document.all) {
          oDoc.innerHTML = oDoc.innerText;
        } else {
          oContent = document.createRange();
          oContent.selectNodeContents(oDoc.firstChild);
          oDoc.innerHTML = oContent.toString();
        }
        oDoc.contentEditable = "true";
      }
      oDoc.focus();
      showHTML.value = !showHTML.value;
      if(showHTML.value){
        showHTMLRef.value.style.backgroundColor = "#e4e7ed";
      }else{
        showHTMLRef.value.style.backgroundColor = "#fff";
      }
    }

    // image setting
    const dialogVisible = ref(false);

    let TextRef = ref(null);

    const insertImg=()=>{
      if (inputSrc.value && inputSrc.value != null) {
        // TextRef.value.focus();
        // TextRef.value.selectionStart = 2;
        // TextRef.value.selectionEnd = 2;
        // if(range.value&&selection.value){
        //   TextRef.value.focus();
        //   selection.value.collapse(range.value);
        // }
        restoreCursor();
        document.execCommand('inserthtml', false, `<img src=${inputSrc.value} draggable>`);
      }
      // inputSrc.value = null;
      dialogVisible.value = false;
    }

    const activeName = ref('first')

    const handleClick = (tab: TabsPaneContext, event: Event) => {
      console.log(tab, event)
    }

    const inputSrc = ref('https://www.baidu.com/img/flexible/logo/pc/result.png');

    const imgToBase = (option) =>{
        let r = new FileReader(); 
        r.readAsDataURL(option.file); 
        r.onload = function(e) {
          (inputSrc.value as string) = e.target.result.toString();
        }
    }

    const state = reactive({
        bannerUrl: '',
        fileList: [],
        fileName: '',
    })

    const handleExceed = (files: File[], fileList) => {
        if (state.fileList.length >= 1) {
            ElMessage.error('只能上传一个图片')
            return;
        }
    }

    const beforeUpload = (file) => {
        let fileArr = file.name.split('.')
        let suffix = fileArr[fileArr.length - 1]
        if (!/(jpg|png|svg)/i.test(suffix)) {
            ElMessage.success('文件格式不正确')
            return false
        }
        return true
    }

    const getCursorCoordinate=(elem)=> {
        const selection = window.getSelection();
        console.log(selection);
        const cursorIndex = getNodeList(elem).findIndex(node =>
            node.firstChild === selection.anchorNode|| node === selection.anchorNode);
        const cursorOffset = selection.anchorOffset;
        return {
            x: selection.anchorOffset,
            y: cursorIndex
        }
    }

    const getNodeList=(elem:Element)=> {
      return Array.from(elem.childNodes);
    }


    // save setting

    const emit = defineEmits(['func'])

    const saveContent = function  () {
        emit('func', TextRef.value.innerHTML)
    }

    // head setting
    const headValue = ref('p')

    const headOptions = [
      {
        value: 'h1',
        label: 'h1',
      },
      {
        value: 'h2',
        label: 'h2',
      },
      {
        value: 'h3',
        label: 'h3',
      },
      {
        value: 'h4',
        label: 'h4',
      },
      {
        value: 'h5',
        label: 'h5',
      },
      {
        value: 'div',
        label: 'div',
      },
      {
        value: 'p',
        label: 'p',
      },
      {
        value: 'pre',
        label: 'pre',
      },
    ]

    const setHeading=(e)=>{
      let headValue = e.target.value;
      applyCommand('formatBlock',headValue);
      // document.execCommand('formatBlock',false,headValue);
    }

    // fontsize setting
    const fontsizeValue = ref('5')

    const fontsizeOptions = [
      {
        value: '1',
        label: '1',
      },
      {
        value: '2',
        label: '2',
      },
      {
        value: '3',
        label: '3',
      },
      {
        value: '4',
        label: '4',
      },
      {
        value: '5',
        label: '5',
      },
      {
        value: '6',
        label: '6',
      },
      {
        value: '7',
        label: '7',
      }
    ]

    const setfontsize=(e)=>{
      let headValue = e.target.value;
      applyCommand('fontsize',headValue);
    }

    // font setting 
    const fontValue = ref('Arial')
    
    const fontOptions = [
      {
        value: 'Arial',
        label: 'Arial',
      },
      {
        value: 'Arial Black',
        label: 'Arial Black',
      },
      {
        value: 'Courier New',
        label: 'Courier New',
      },
      {
        value: 'Times New Roman',
        label: 'Times New Roman',
      }
    ]

    const setfont=(e)=>{
      let headValue = e.target.value;
      applyCommand('fontname',headValue);
    }

    // link setting
    const inputURL = ref('');
    const inputURLTitle = ref('');
    const linkDialogVisible = ref(false);
    const setLink=()=>{
      // TextRef.value.focus();
      restoreCursor();
      document.execCommand('inserthtml', false, `<a href="${inputURL.value}" target='_blank'>${inputURLTitle.value}</a>`);
      // document.execCommand('createLink', false, inputURL.value);
      linkDialogVisible.value = false
    }

    // cursor setting 
    const currentCursor = ref('');
    const selection = ref(null);
    // const range = ref(null);

    const range = ref(null);

    const saveCursors=()=>{
      saveContent();
      range.value = saveSelection();
    }

    const restoreCursor = ()=>{
      restoreSelection(range.value)
    }

    const saveSelection=()=> {
        if (window.getSelection) {
            let sel = window.getSelection();
            if (sel.getRangeAt && sel.rangeCount) {
                return sel.getRangeAt(0);
            }
        }
        return null;
    }

    const restoreSelection=(range)=> {
        if (range) {
            if (window.getSelection) {
                let sel = window.getSelection();
                sel.removeAllRanges();
                sel.addRange(range);
            }
        }
    }

    // background-color setting
    const bgcColor = ref('#eee');
    const changeBgcColor = () =>{
      restoreCursor();
      applyCommand('backcolor',bgcColor.value);
    }

    // background-color setting
    const fontColor = ref('#000');
    const changeFontColor = () =>{
      restoreCursor();
      applyCommand('forecolor',fontColor.value);
    }
</script>

<style scoped lang="less">
.editor-container{
  height: 400px;
  border: 3px solid #e4e7ed;
  border-top: 0;
  box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.12);
  .editor-header{
    box-sizing: border-box;
    height: 95px;
    top: 0;
    border-bottom: 2px solid #e4e7ed;
    border-top: 2px solid #e4e7ed;
    position: sticky;
    background-color: #fff;
  }
  img{
    padding: 10px 5px;
    width: 30px;
    height: 30px;
  }
  img:hover{
    background-color: #e4e7ed;
  }
  .header-setting{
    margin: 0 5px 5px;
    padding: 5px;
    border-radius: 5px;
    line-height: 30px;
  }
  .editor-content{
    height: calc(100% - 95px);
    box-sizing: border-box;
    overflow: auto;
    padding: 15px;
    outline: none;
    font-size: 20px;
  }

  #sourceText {
      padding: 0;
      margin: 0;
      min-height: 400px;
  }

  #editMode label {
      cursor: pointer;
  }
}
</style>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值