vue中使用可编辑div 自定义输入框 写一个可以插入特定元素后继续输入文案的输入框 属性 contenteditable="true"

254 阅读5分钟

自定义输入框 可生成指定元素 属性 contenteditable="true"

产品需求

** 输入框上面有数据返回的tag选项 当用户输入文案后点击上方tag标签下方插入对应的tag标签后用户可以继续输入文字 **

WeChat5993004b307bd9fe67ce60597367e206.jpg

主要实现 addStrBtnFun函数插入一个button元素 问题需要处理光标位置 给button设置 contenteditable="false" 可以防止删除文案的时候光标出现在button内 设置了false 就会将button认为一个整体 一起删除 下面代码实例

    <div class="chitchat">
        <div style="width:32px;height:32px;">
            <img src="https://file.fazhuyun.com/headsculpture/法助云.png" alt="" width="100%" height="100%">
        </div>
        <div id="editDivBody" :class="isInspection ? 'Inspection' : ''">
            <div style="margin-bottom:12px">
                <el-button class="nickname" @click="addStrBtnFun(addBtnText)">{{ addBtnText }}</el-button>
                <el-button class="nickname" @click="addStrBtnFun(serviceConsultant)">{{ serviceConsultant }}</el-button>
                <el-button class="nickname" @click="addStrBtnFun(departments)">{{ departments }}</el-button>
                <el-button @click="getSpecialContent">点我</el-button>
                <!-- <el-button @click="checkIsNonData">是否空</el-button> -->
            </div>
            <div class="headPortrait" v-if="uploadHtml">
                <div :disabled="textSize >= maxNum" ref="editInput" class="edit" id="editSelDiv" contenteditable="true"
                    @keydown="handleKeydown" @input="editInput" placeholder="请输入消息内容" v-html="innerHtml"
                    @paste="handlePaste">
                </div>
                实时数据1:
                {{ nowHtmlT }}
                <hr>
                实时数据2:{{ nowText }}
            </div>

        </div>
        <div class="limit">{{ textSize }}/{{ maxNum }}</div>
        <p v-if="isInspection" class="Contentfilling">请填写消息内容</p>
    </div>
</template>

<script>
let editDiv = null //编辑元素
let range = "" //选区
export default {
    data() {
        return {
            addBtnText: '客户昵称',
            serviceConsultant: '所属顾问',
            departments: '所属部门',
            // 赋值
            innerHtml: '',// 初始化赋值
            innerHtmlBf: '',//备份数据
            nowHtmlT: '',// 实时html-动态变量
            nowText: '',// 实时文本
            // 获取当前最新数据
            getDataTime: 1000,
            getDataTimeInter: null,
            textSize: 0,
            maxNum: 500,
            isInspection: false,
            uploadHtml: true,

        }
    },
    mounted() {
        this.initEditFun()
    },
    methods: {
        //删除监听
        handleKeydown(event) {
            return

            if (event.key === 'Backspace') {
                const selection = window.getSelection();
                const range = selection.getRangeAt(0);
                const node = range.startContainer;
                const offset = range.startOffset;
                // console.log(node,offset,)
                // 获取光标前面的节点
                const precedingNode = node.childNodes[offset - 1];
                console.log(precedingNode.nodeName, '====')
                // 检查光标是否在 <p> 标签后面
                if (precedingNode && precedingNode.nodeName === 'SPAN' && precedingNode.classList.contains('selColor')) {
                    precedingNode.parentNode.removeChild(precedingNode);
                    event.preventDefault();
                }
            }
        },
        initEditFun() {
            editDiv = document.getElementById("editSelDiv")
            editDiv.addEventListener("blur", () => {
                // 这一步是保留住edit编辑框中的选区的范围对象。
                // 否则失焦后,getSelection()方法返回的选区对象已经不再是编辑框了,已经获取不到编辑框中的范围对象了。
                range = window.getSelection() ? window.getSelection().getRangeAt(0) : ''
            })
            document.addEventListener('selectionchange', (e) => {
                let selection = document.getSelection();
                let rangeVal = selection && selection.rangCount > 0 ? selection.getRangeAt(0) : ''
                if (
                    (rangeVal && this.hasAncestorWithId(rangeVal.commonAncestorContainer, 'editSelDiv'))
                ) {
                    range = window.getSelection() ? window.getSelection().getRangeAt(0) : ''
                }

            });
        },
        // 递归判断是否有父级id = editSelDiv
        hasAncestorWithId(element, id) {
            if (!element) return false;
            if (element.id === id) return true;
            if (element.parentNode) {
                return this.hasAncestorWithId(element.parentNode, id);
            }
            return false;
        },
        editInput(e) {
            console.log(e.target.innerHtml);
            console.log(e.target.innerText);
            console.log(this.innerHtml, '====')
            this.getNowContent() //获取最新内容
            if (e.target.children.length > 0) {
                this.editSpanClass()
            }
            if (e.target.innerText == '') {
                range = ''
                e.target.innerHtml = ''
                this.innerHtml = ''
                this.innerHtmlBf = ''
            }
        },
        editSpanClass() {
            let spanAll = document.querySelectorAll('.edit span')
            spanAll.forEach(item => {
                let nowVal = item.childNodes[0].data
                if (
                    nowVal &&
                    nowVal.trim() != this.addBtnText &&
                    nowVal.trim() != this.serviceConsultant &&
                    nowVal.trim() != this.departments
                ) {
                    item.className = 'mrClass'
                }
            })
        },
        // 粘贴
        handlePaste(e) {
            e.preventDefault(); // 阻止默认粘贴操作
            const clipboardData = e.clipboardData || window.clipboardData;
            const text = clipboardData.getData('text/plain'); // 获取纯文本内容
            document.execCommand('insertHTML', false, text); // 将文本插入 contenteditable 元素中
        },
        drawImage(mytext) {
            // 创建一个 Canvas 元素
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');

            // 设置 Canvas 的大小
            const width = 30;
            const height = 14;
            canvas.width = width;
            canvas.height = height;

            // 绘制背景色
            ctx.fillStyle = '#ecf5ff';
            ctx.fillRect(0, 0, width, height);

            // 绘制边框
            // ctx.strokeStyle = '#d9ecff';
            // ctx.lineWidth = 1;
            // ctx.strokeRect(0, 0, width, height);

            // 计算文字区域的大小
            const padding = 4;
            const textWidth = width - 2 * padding;
            const textHeight = height - 2 * padding;

            // 绘制文字
            ctx.fillStyle = '#409eff';
            ctx.font = '12px Arial';
            const text = mytext;
            const textMetrics = ctx.measureText(text);
            const textX = padding + (textWidth - textMetrics.width) / 2;
            const textY = padding + (textHeight + 12) / 2;

            ctx.fillText(text, textX, textY);

            // 将 Canvas 内容转换为图片
            const img = new Image();
            img.src = canvas.toDataURL();

            // 插入到指定的 div 元素中
            // const box = document.getElementById('box');
            // box.innerHTML = ''; // 清空 box 中的内容
            // box.appendChild(img);
            return img
        },
        addStrBtnFun(Text) {
            const innerText = this.$refs.editInput.innerText
            const now_length = innerText.split('\u200B').join('').length
            const length = now_length + Text.length //当前长度+按钮文字长度
            if (length > this.maxNum) {
                return
            }
            const span = document.createElement('span')
            //contenteditable设置为fasle不可编辑
            span.innerHTML = `<button contenteditable="false" class="btn" id="btn1" style='background-color: #ecf5ff;display: inline-block;height: 24px;padding: 0 10px;line-height: 24px;font-size: 12px;color: #409eff; border: 1px solid #d9ecff;border-radius: 4px;box-sizing: border-box;white-space: nowrap;margin: 0 4px'>${Text}</button>`
            // span.src = 'https://file.fazhuyun.com/headsculpture/法助云.png'
            // span.style.backgroundColor = 'red'
            // span.style.width = '50px'; // 设置图片宽度
            // span.style.height = '50px'; // 设置图片高度
            // span.innerText = Text
            // span.contenteditable=false
            span.className = 'selColor'
            // 如果在页面刷新再点击编辑框之前就点击了按钮,此时range中并没有选区范围对象
            if (range === "") {
                let selection = window.getSelection()
                selection.selectAllChildren(editDiv) // selectAllChildren把指定元素的所有子元素设为选中区域,并取消之前的选中区域。不包括node节点本身。
                /*
                  Selection.collapseToEnd() 方法的作用是取消当前选区,并把光标定位在原选区的最末尾处,如果此时光标所处的位置是可编辑的,且它获得了焦点,则光标会在原地闪烁。
                  以上selectAllChildren方法,将div中子节点全部选中,collapseToEnd方法将选中区域取消,并且将光标定位到原区的末尾。
                */
                selection.collapseToEnd()
                range = window.getSelection() ? window.getSelection().getRangeAt(0) : ''// 无论哪一步都需要保存当前编辑框的选区对象
            }
            let sel = window.getSelection()
            let space = document.createTextNode('\u200B'); // 创建 &ZeroWidthSpace; 实体字符节点
            range.insertNode(space)
            range.insertNode(span) // insertNode方法,在range选区开头插入一个节点
            /*
            removeAllRanges方法:删除之前的所有选区。
            这一步的意义:因为当我们点击其他区域时,选区对象已经改变,不再是编辑框中的选区对象,这时候我们接下来的操作都不会符合我们想象中的样子
           */
            sel.removeAllRanges()
            sel.addRange(range) // 这一步就是添加当前区域对象到选区对象中,所以选区对象会再次指向编辑框中的选区,不会出现别的错误操作。
            sel.collapseToEnd()

            this.editSpanClass()
            this.getNowContent()
        },
        resevedSelDiv() {
            range = document.createRange();
            range.selectNode(document.getElementById("editSelDiv").lastChild);
            window.getSelection().removeAllRanges();
            window.getSelection().addRange(range);
            let selection = window.getSelection()
            selection.selectAllChildren(editDiv)
            selection.collapseToEnd()
            range = window.getSelection() ? window.getSelection().getRangeAt(0) : ''
            console.log(333);
        },
        // 获取最新的内容
        getNowContent() {
            const innerText = this.$refs.editInput.innerText
            const length = innerText.split('\u200B').join('').length
            if (length <= this.maxNum) {
                this.innerHtmlBf = this.$refs.editInput.innerHTML
                this.nowText = this.$refs.editInput.innerText
                this.textSize = length
                this.isInspection = false
            } else {
                this.uploadHtml = false
                this.$nextTick(() => {
                    this.innerHtml = this.innerHtmlBf
                    this.uploadHtml = true
                    setTimeout(() => {
                        this.resevedSelDiv()
                    }, 1)
                });
            }
        },
        // 获取带符号的内容
        getSpecialContent() {
            let spanAll = document.querySelectorAll('.edit span')
            let mrArr = []
            spanAll.forEach(item => {
                let nowVal = item.childNodes[0].data
                mrArr.push(nowVal)
                if (item.className == 'selColor') {
                    if (nowVal == this.addBtnText) {
                        item.childNodes[0].data = '${customerNickname}' //客户昵称
                    } else if (nowVal == this.serviceConsultant) {
                        item.childNodes[0].data = '${affiliatedConsultant}'//所属顾问
                    } else if (nowVal == this.departments) {
                        item.childNodes[0].data = '${department}' //所属部门
                    }
                }
            })
            console.log(spanAll, '-----')
            this.nowHtmlT = this.$refs.editInput.innerText
            this.nowHtmlT = this.replaceText(this.nowHtmlT, this.addBtnText, "${customerNickname}")
            this.nowHtmlT = this.replaceText(this.nowHtmlT, this.serviceConsultant, "${affiliatedConsultant}")
            this.nowHtmlT = this.replaceText(this.nowHtmlT, this.departments, "${department}")
            spanAll.forEach((item, i) => {
                item.childNodes[0].data = mrArr[i]
            })
            this.$emit('nowHtmlT', this.nowHtmlT)
        },
        replaceText(text, oldStr, newStr) {
            // 检查是否为字符串类型
            if (typeof text !== 'string') {
                text = String(text);
            }
            // 替换字符
            text = text.replace(new RegExp(oldStr, "g"), newStr);
            // 处理子级
            if (text.includes('<') && text.includes('>')) {
                const start = text.indexOf('<');
                const end = text.indexOf('>') + 1;
                let subtext = text.substring(start, end);
                while (start >= 0 && end >= 0 && end > start) {
                    const subtextNew = replaceText(subtext, oldStr, newStr);
                    text = text.substring(0, start) + subtextNew + text.substring(end);
                    subtext = text.substring(start, end);
                }
            }
            return text;
        },
        checkIsNonData() {
            const innerText = this.$refs.editInput.innerText
            const length = innerText.split('\u200B').join('').length
            if (innerText == '' || innerText == '\n' || length == 0) {
                this.isInspection = true
            } else {
                this.isInspection = false
            }
        },
    },
    beforeDestroy() {
        editDiv = null //编辑元素
        range = "" //选区
    }
}
</script>
<style lang="scss" scoped>
.edit {
    width: 100%;
    height: auto;
    min-height: 130px;
    max-height: 135px;
    overflow-y: auto;
}

.edit:focus {
    outline: 0;
    border-color: #409EFF;
}

#editDivBody {
    width: 100%;
    //height:203px;
    overflow-y: auto;
    background: #FAFBFC;
    border-radius: 4px;
    border: 1px solid #DCDEE6;
    padding: 12px 12px 20px 13px;
    margin-left: 8px;
    position: relative;
}

::-webkit-scrollbar {
    /*滚动条整体样式*/
    width: 5px;
    /*高宽分别对应横竖滚动条的尺寸*/
    height: 1px;
}

::-webkit-scrollbar-thumb {
    /*滚动条里面小方块*/
    border-radius: 5px;
    background-color: #D8D8D8;
}

::-webkit-scrollbar-track {
    background-color: #f6f6f6;
}

::-webkit-scrollbar-thumb,
::-webkit-scrollbar-track {
    border: 0;
}

.Inspection {
    border: 1px solid #FF4949 !important;
}

.nickname {
    background: #FFFFFF;
    border-radius: 4px;
    border: 1px solid #DCDFE6;
    font-size: 12px;
    font-weight: 400;
    color: #1890FF;
    line-height: 17px;
    padding: 4px 8px;
}

.chitchat {
    display: flex;
    position: relative;
    width: 79%;
}

.Contentfilling {
    width: 84px;
    height: 17px;
    font-size: 12px;
    line-height: 17px;
    color: #FF4949;
    position: absolute;
    bottom: -18px;
    left: 41px;
    margin: 0;
}

::v-depp.selColor {
    color: #1890FF;
    font-size: 14px;
}

::v-depp.mrClass {
    color: #000;
    font-size: 14px;
    background-color: red;
}

.limit {
    position: absolute;
    bottom: 0;
    right: 0;
    padding: 0 8px;
    line-height: 22px;
    font-size: 14px;
    color: #86909C;
}

.headPortrait {
    position: relative;
    font-size: 14px;
    border: 1px red solid;
}

::v-depp .headPortrait button {
    background-color: #ecf5ff;
    display: inline-block;
    height: 32px;
    padding: 0 10px;
    line-height: 30px;
    font-size: 12px;
    color: #409eff;
    border: 1px solid #d9ecff;
    border-radius: 4px;
    box-sizing: border-box;
    white-space: nowrap;
}
</style>