前端HTML contenteditable 属性使用指南
什么是 contenteditable?
- HTML5 提供的全局属性,使元素内容可编辑
- 类似于简易富文本编辑器
- 兼容性
支持所有现代浏览器(Chrome、Firefox、Safari、Edge)
移动端(iOS/Android)部分键盘行为需测试
<p contenteditable="true">可编辑的段落</p>
属性值说明
contenteditable
的三种值:
true
:元素可编辑
false
:元素不可编辑
inherit
:继承父元素的可编辑状态
<p contenteditable="false">不可编辑的段落</p>
<div contenteditable="true">点击编辑此内容</div>
<p contenteditable="inherit">继承父元素的可编辑状态</p>
核心功能实现
保存编辑内容
<div style="margin-left: 36px;"v-html="newData" contenteditable="true" ref="ediPending2Div" class="editable" @blur="updateContent"@input="handleInput"@focus="saveCursorPosition"@keydown.enter.prevent="handleEnterKey"></div>
// 更新内容updateContent() {this.isEditing = falseif (this.rawData !== this.editContent) {this.submitChanges()this.editContent = this.rawData}},
编辑时光标位置的设置
<div style="margin-left: 36px;"v-html="newData" contenteditable="true" ref="ediPending2Div" class="editable" @blur="updateContent"@input="handleInput"@focus="saveCursorPosition"@keydown.enter.prevent="handleEnterKey"></div>
// 保存光标位置saveCursorPosition() {const selection = window.getSelection()if (selection.rangeCount > 0) {const range = selection.getRangeAt(0)this.lastCursorPos = {startContainer: range.startContainer,startOffset: range.startOffset,endOffset: range.endOffset}}},// 恢复光标位置restoreCursorPosition() {if (!this.lastCursorPos || !this.isEditing) returnconst selection = window.getSelection()const range = document.createRange()try {range.setStart(this.lastCursorPos.startContainer,Math.min(this.lastCursorPos.startOffset, this.lastCursorPos.startContainer.length))range.setEnd(this.lastCursorPos.startContainer,Math.min(this.lastCursorPos.endOffset, this.lastCursorPos.startContainer.length))selection.removeAllRanges()selection.addRange(range)} catch (e) {// 出错时定位到末尾range.selectNodeContents(this.$refs.ediPending2Div)range.collapse(false)selection.removeAllRanges()selection.addRange(range)}},// 处理输入handleInput() {this.saveCursorPosition()this.rawData = this.$refs.ediPending2Div.innerHTML},
处理换行失败的问题(需要回车两次触发)
// 给数组添加回车事件handleEnterKey(e) {// 阻止默认回车行为(创建新div)e.preventDefault();// 获取当前选区const selection = window.getSelection();if (!selection.rangeCount) return;const range = selection.getRangeAt(0);const br = document.createElement('br');// 插入换行range.deleteContents();range.insertNode(br);// 移动光标到新行range.setStartAfter(br);range.collapse(true);selection.removeAllRanges();selection.addRange(range);// 触发输入更新this.handleInput();},
踩坑案例
- 数组遍历标签上不能够使用此事件
contenteditable
完整代码展示
- 带数组的处理
- 不带数组的处理
带数组代码
<template><div style="margin-left: 36px;" v-loading="loading_" contenteditable="true" ref="editPendingDiv" class='editable'@blur="updateContent"@input="handleInput"@focus="saveCursorPosition"@keydown.enter.prevent="handleEnterKey"><p class="pending_title">会议待办</p><p>提炼待办事项如下:</p><div v-for="(item, index) in newData" :key="index" class="todo-item"><div class="text_container"><!-- <img src="@/assets/404.png" alt="icon" class="icon-img"> --><p><span class="icon-span">AI</span> {{ item }}</p></div></div></div>
</template><script>
// 会议待办事项组件
import { todoList } from '@/api/audio';
import router from '@/router';
export default {name: 'pendingResult',props: {// items: {// type: Array,// required: true// }},data() {return {rawData:null,editContent: '', // 编辑内容缓存lastCursorPos: null, // 光标位置记录isEditing: false,loading_:false,dataList: [] ,routerId: this.$route.params.id};},computed: {newData () {// 在合格换行后下面添加margin-botton: 10pxreturn this.dataList}},watch: {newData() {this.$nextTick(this.restoreCursorPosition)this.$nextTick(this.sendHemlToParent)}},mounted() {this.$refs.editPendingDiv.addEventListener('focus', () => {this.isEditing = true})},created() {this.getDataList();},methods: {// 给数组添加回车事件handleEnterKey(e) {// 阻止默认回车行为(创建新div)e.preventDefault();// 获取当前选区const selection = window.getSelection();if (!selection.rangeCount) return;const range = selection.getRangeAt(0);const br = document.createElement('br');// 插入换行range.deleteContents();range.insertNode(br);// 移动光标到新行range.setStartAfter(br);range.collapse(true);selection.removeAllRanges();selection.addRange(range);// 触发输入更新this.handleInput();},// 发送生成数据sendHemlToParent(){this.$nextTick(()=>{const htmlString = this.$refs.editPendingDiv.innerHTMLconsole.log('获取修改',htmlString)this.$emit('editList',htmlString)})},// 保存光标位置saveCursorPosition() {const selection = window.getSelection()if (selection.rangeCount > 0) {const range = selection.getRangeAt(0)this.lastCursorPos = {startContainer: range.startContainer,startOffset: range.startOffset,endOffset: range.endOffset}}},// 恢复光标位置restoreCursorPosition() {if (!this.lastCursorPos || !this.isEditing) returnconst selection = window.getSelection()const range = document.createRange()try {range.setStart(this.lastCursorPos.startContainer,Math.min(this.lastCursorPos.startOffset, this.lastCursorPos.startContainer.length))range.setEnd(this.lastCursorPos.startContainer,Math.min(this.lastCursorPos.endOffset, this.lastCursorPos.startContainer.length))selection.removeAllRanges()selection.addRange(range)} catch (e) {// 出错时定位到末尾range.selectNodeContents(this.$refs.editPendingDiv)range.collapse(false)selection.removeAllRanges()selection.addRange(range)}},// 处理输入handleInput() {this.saveCursorPosition()this.rawData = this.$refs.editPendingDiv.innerHTML},// 更新内容// updateContent() {// this.isEditing = false// if (this.rawData !== this.editContent) {// this.submitChanges()// this.editContent = this.rawData// }// },updateContent() {this.isEditing = false;// 清理HTML格式const cleanedHTML = this.rawData.replace(/<div><br><\/div>/g, '<br>').replace(/<p><br><\/p>/g, '<br>');if (cleanedHTML !== this.editContent) {this.submitChanges(cleanedHTML);}
},// 提交修改submitChanges() {// 这里添加API调用逻辑console.log('提交内容:', this.rawData)this.$emit('editList',this.rawData)},async getDataList() {const id = {translate_task_id: this.routerId};this.loading_=truetry {const res=await todoList(id)if (res.code === 0) { if (res.data.todo_text == [] || res.data.todo_text === null) {this.$message.warning("暂无待办事项");return;}// console.log("会议纪要数据:", res.data);this.dataList=res.data.todo_text}} finally {this.loading_=false}// const normalizedText = res.data.todo_text.replace(/\/n/g, '\n');// // 分割文本并过滤空行// this.dataList = normalizedText.split('\n')// .filter(line => line.trim().length > 0)// .map(line => line.trim());}}
}
</script><style scoped>
.pending_title {/* font-size: 20px; *//* font-family: "宋体"; *//* font-weight: bold; */margin-bottom: 20px;
}
.text_container {display: flex;align-items: center;
}
.icon-img {width: 20px;height: 20px;margin-right: 10px;
}
.editable {/* 确保可编辑区域行为正常 */user-select: text;white-space: pre-wrap;outline: none;
}.todo-item {display: flex;align-items: center;margin: 4px 0;
}/* 防止图片被选中 */
.icon-span {pointer-events: none;user-select: none;margin-right: 6px;font-weight: 700; color: #409EFF;
}</style>
不带数组代码
<template><div><div style="margin-left: 36px;"v-html="newData" contenteditable="true" ref="ediPending2Div" class="editable" @blur="updateContent"@input="handleInput"@focus="saveCursorPosition"@keydown.enter.prevent="handleEnterKey"></div></div>
</template><script>
// 会议待办事项组件222
export default {name: 'pendingResult2',props: {dataList: {type: Object,required: true}},data() {return {rawData:null,editContent: '', // 编辑内容缓存lastCursorPos: null, // 光标位置记录isEditing: false,};},computed: {newData () {return this.dataList.todo_text}},watch: {newData() {this.$nextTick(this.restoreCursorPosition)}},mounted() {this.$refs.ediPending2Div.addEventListener('focus', () => {this.isEditing = true})},created() {// console.log(":", this.dataList);},methods: {// 给数组添加回车事件handleEnterKey(e) {// 阻止默认回车行为(创建新div)e.preventDefault();// 获取当前选区const selection = window.getSelection();if (!selection.rangeCount) return;const range = selection.getRangeAt(0);const br = document.createElement('br');// 插入换行range.deleteContents();range.insertNode(br);// 移动光标到新行range.setStartAfter(br);range.collapse(true);selection.removeAllRanges();selection.addRange(range);// 触发输入更新this.handleInput();},// 保存光标位置saveCursorPosition() {const selection = window.getSelection()if (selection.rangeCount > 0) {const range = selection.getRangeAt(0)this.lastCursorPos = {startContainer: range.startContainer,startOffset: range.startOffset,endOffset: range.endOffset}}},// 恢复光标位置restoreCursorPosition() {if (!this.lastCursorPos || !this.isEditing) returnconst selection = window.getSelection()const range = document.createRange()try {range.setStart(this.lastCursorPos.startContainer,Math.min(this.lastCursorPos.startOffset, this.lastCursorPos.startContainer.length))range.setEnd(this.lastCursorPos.startContainer,Math.min(this.lastCursorPos.endOffset, this.lastCursorPos.startContainer.length))selection.removeAllRanges()selection.addRange(range)} catch (e) {// 出错时定位到末尾range.selectNodeContents(this.$refs.ediPending2Div)range.collapse(false)selection.removeAllRanges()selection.addRange(range)}},// 处理输入handleInput() {this.saveCursorPosition()this.rawData = this.$refs.ediPending2Div.innerHTML},// 更新内容updateContent() {this.isEditing = falseif (this.rawData !== this.editContent) {this.submitChanges()this.editContent = this.rawData}},// 提交修改submitChanges() {// 这里添加API调用逻辑console.log('提交内容:', this.rawData)this.$emit('editList',this.rawData)},getDataList() {},},
}
</script><style scoped>::v-deep .el-loading-mask{display: none !important;
}
p {/* margin: 0.5em 0; *//* font-family: "思源黑体 CN Regular"; *//* font-size: 18px; */
}
img {width: 20px;height: 20px;margin-right: 10px;
}
.indent_paragraph {text-indent: 2em; /* 默认缩进 */
}
.pending_title {/* font-size: 20px; *//* font-family: "宋体"; *//* font-weight: bold; */margin-bottom: 20px;
}
.text_container {display: flex;align-items: center;
}
.icon-img {width: 20px;height: 20px;margin-right: 10px;
}
.editable {/* 确保可编辑区域行为正常 */user-select: text;white-space: pre-wrap;outline: none;
}.todo-item {display: flex;align-items: center;margin: 4px 0;
}/* 防止图片被选中 */
.icon-span {pointer-events: none;user-select: none;margin-right: 6px;font-weight: 700; color: #409EFF;
}</style>