前端HTML contenteditable 属性使用指南

​​什么是 contenteditable?

  • HTML5 提供的全局属性,使元素内容可编辑
  • 类似于简易富文本编辑器
  • 兼容性
    支持所有现代浏览器(Chrome、Firefox、Safari、Edge)
    移动端(iOS/Android)部分键盘行为需测试
html 复制代码
<p contenteditable="true">可编辑的段落</p>

属性值说明
contenteditable 的三种值:
true:元素可编辑
false:元素不可编辑
inherit:继承父元素的可编辑状态

html 复制代码
<p contenteditable="false">不可编辑的段落</p>
<div contenteditable="true">点击编辑此内容</div>
<p contenteditable="inherit">继承父元素的可编辑状态</p>

核心功能实现​

保存编辑内容​
html 复制代码
  <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>
javascript 复制代码
   // 更新内容
    updateContent() {
      this.isEditing = false
      if (this.rawData !== this.editContent) {
        this.submitChanges()
        this.editContent = this.rawData
      }
    },
编辑时光标位置的设置
html 复制代码
  <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>
javascript 复制代码
 // 保存光标位置
    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
    },
处理换行失败的问题(需要回车两次触发)
javascript 复制代码
    // 给数组添加回车事件
    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

完整代码展示

  • 带数组的处理
  • 不带数组的处理

带数组代码

javascript 复制代码
<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>

不带数组代码

javascript 复制代码
<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>
效果展示
相关推荐
xiaogg36783 分钟前
网站首页菜单两种布局vue+elementui顶部和左侧栏导航
前端·vue.js·elementui
神膘护体小月半3 分钟前
bug 记录 - 使用 el-dialog 的 before-close 的坑
前端·javascript·bug
&白帝&7 分钟前
使用vite-plugin-html在 HTML 文件中动态注入数据,如元数据、环境变量、标题
前端·html·dreamweaver
SouthernWind8 分钟前
RAGFlow构建知识库和联网搜索对话平台:从零到一的完整开发指南
前端·javascript
我是小七呦12 分钟前
😧纳尼?前端也能做这么复杂的事情了?
前端·面试·ai编程
陈_杨16 分钟前
鸿蒙5开发宝藏案例分享---性能优化案例解析
前端
前端付豪19 分钟前
揭秘网易统一日志采集与故障定位平台揭秘:如何在亿级请求中1分钟定位线上异常
前端·后端·架构
香蕉可乐荷包蛋30 分钟前
vue对axios的封装和使用
前端·javascript·vue.js·axios
娃哈哈哈哈呀34 分钟前
html - <mark>标签
前端·html
QQ_hoverer34 分钟前
前端使用 preview 插件预览docx文件
前端·javascript·layui·jquery