简易富文本
概述
其实想要实现富文本很容易
简易的富文本主要功能一共两部分
- 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>