前端HTML contenteditable 属性使用指南

发布于:2025-06-06 ⋅ 阅读:(20) ⋅ 点赞:(0)

​​什么是 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 = false
      if (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) return
      
      const 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: 10px
      return 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.innerHTML
        console.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) return
      
      const 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_=true
     try {
      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) return
      
      const 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 = false
      if (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>

效果展示

在这里插入图片描述