基于 contenteditable 实现变量插入富文本编辑器

目录

[第一章 前言](#第一章 前言)

[第二章 实现](#第二章 实现)

[2.1 组件功能概览](#2.1 组件功能概览)

[2.2 实现思路](#2.2 实现思路)

[2.2.1 富文本核心:contenteditable](#2.2.1 富文本核心:contenteditable)

[2.2.2 标签解析与序列化](#2.2.2 标签解析与序列化)

[2.2.3 光标定位与弹窗跟随](#2.2.3 光标定位与弹窗跟随)

[2.3.4 中文输入法兼容处理](#2.3.4 中文输入法兼容处理)

[2.3.5 Teleport 解决层级问题](#2.3.5 Teleport 解决层级问题)

[2.3.6 双向绑定防死循环机制](#2.3.6 双向绑定防死循环机制)

[第三章 完整代码](#第三章 完整代码)


第一章 前言

在目前的很多AI系统,后台管理系统、表单模板配置、消息推送、问卷系统等业务中,我们经常需要实现一类通用需求:在输入框中输入 / 或者 @ 唤起变量列表 → 选择变量自动插入为标签 → 支持删除标签 → 数据双向绑定 → 支持回显与编辑。本文基于 Vue3 + 原生 DOM API,实现一套轻量、无第三方依赖。

第二章 实现

2.1 组件功能概览

本组件已实现的完整能力:

  1. v-model 双向绑定 ,数据格式为 {``{id|name}}
  2. 输入 / 自动触发变量选择面板
  3. 支持关键词模糊搜索过滤变量列表
  4. 光标实时定位,弹窗跟随光标位置
  5. 键盘 ↑↓ 选择、Enter 确认、ESC 关闭
  6. 插入不可编辑标签,标签支持删除
  7. 支持 placeholder、禁用状态、聚焦样式
  8. 粘贴自动过滤为纯文本,防止富文本污染
  9. 兼容中文输入法(composition 事件处理)
  10. 使用 Teleport 挂载弹窗到 body,解决层级遮挡
  11. 支持外部通过 ref.focus() 主动聚焦

数据存储格式示例:

javascript 复制代码
"测试描述{{22bb83a1e45b4f1ea0db456a87eb842e|机构性质}} {{436979cd47234041adbbb22285cb9b81|测试印章2}} {{8a5e203679a7486f99327ca7ef75a869|机构地址}} ?"

2.2 实现思路

2.2.1 富文本核心:contenteditable

  • contenteditable 是 HTML 提供的一个非常强大的属性,它能瞬间把任何元素变成一个可编辑区域。借助它,我们可以轻松实现在线笔记、表格编辑、富文本编辑器等功能。
html 复制代码
<div
  ref="editorRef"
  class="mention-textarea__editor"
  :contenteditable="!disabled"
></div>
  • contenteditable="true":让普通 div 变成浏览器原生可编辑区域
  • 优点:轻量、可控、不依赖任何富文本库
  • 难点:光标管理、DOM 序列化、数据同步

2.2.2 标签解析与序列化

这是整个功能最核心的逻辑,实现 字符串 ↔ DOM 互相转换

  • 匹配格式:{{变量ID|变量名}};
javascript 复制代码
const TAG_REGEX = /\{\{([^|]+)\|([^}]+)\}\}/g
  • 字符串 → DOM 片段
javascript 复制代码
function parseToSegments(str) {
  // 拆分文本片段与标签片段
}
  • DOM 片段 → 可存储字符串
javascript 复制代码
function serializeFromDOM(el) {
  // 遍历 DOM 节点,还原为 {{id|name}} 格式
}
  • 作用:
  1. 编辑时:DOM 结构 → 字符串,用于提交后端
  2. 回显时:字符串 → DOM 结构,用于页面渲染

2.2.3 光标定位与弹窗跟随

javascript 复制代码
function getCaretRect() {
  const sel = window.getSelection()
  const range = sel.getRangeAt(0)
  return range.getBoundingClientRect()
}
  • window.getSelection():获取用户选区对象
  • getRangeAt(0):获取光标范围
  • getClientRects():获取光标坐标
  • 零宽字符 \u200b 兼容空行光标位置获取

2.3.4 中文输入法兼容处理

javascript 复制代码
const isComposing = ref(false)

function handleCompositionStart() {
  isComposing.value = true
}
function handleCompositionEnd() {
  isComposing.value = false
  handleInput()
}
  • 监听 compositionstart / compositionend 避免中文输入
  • 未上屏时触发搜索、校验、弹窗

2.3.5 Teleport 解决层级问题

javascript 复制代码
<Teleport to="body">
  <div class="mention-popup" :style="popupAbsStyle">
    ...
  </div>
</Teleport>

2.3.6 双向绑定防死循环机制

javascript 复制代码
let skipNextWatch = false

function emitValue() {
  skipNextWatch = true
  // ... 触发更新
}

watch(() => props.modelValue, (newVal) => {
  if (skipNextWatch) {
    skipNextWatch = false
    return
  }
  // ... 重新渲染
})
  • 自身触发更新时,跳过监听,避免无限循环
  • 外部修改 modelValue 时正常响应并重新渲染

第三章 完整代码

javascript 复制代码
<script setup>
import {
  ref, computed, watch, nextTick,
  onMounted, onUnmounted, throttle, debounce
} from 'vue'

const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  },
  mentionOptions: {
    type: Array,
    default: () => []
  },
  placeholder: {
    type: String,
    default: '请输入内容,输入 / 可插入变量'
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['update:modelValue'])

const editorRef = ref(null)
const showPopup = ref(false)
const popupStyle = ref({ top: '0px', left: '0px' })
const searchText = ref('')
const selectedIndex = ref(0)
const slashInfo = ref(null)
const isFocused = ref(false)
const isComposing = ref(false)

// 用于清理标签关闭事件,防止内存泄漏
const tagClickHandlers = new WeakMap()

const filteredOptions = computed(() => {
  if (!searchText.value) return props.mentionOptions
  const keyword = searchText.value.toLowerCase()
  return props.mentionOptions.filter(opt =>
    opt.name.toLowerCase().includes(keyword)
  )
})

watch(filteredOptions, () => {
  selectedIndex.value = 0
})

const TAG_REGEX = /\{\{([^|]+)\|([^}]+)\}\}/g

// 字符串解析为片段
function parseToSegments(str) {
  if (!str) return []
  const segments = []
  let lastIndex = 0
  const re = new RegExp(TAG_REGEX.source, 'g')
  let match
  while ((match = re.exec(str)) !== null) {
    if (match.index > lastIndex) {
      segments.push({ type: 'text', value: str.slice(lastIndex, match.index) })
    }
    segments.push({ type: 'tag', id: match[1], name: match[2] })
    lastIndex = re.lastIndex
  }
  if (lastIndex < str.length) {
    segments.push({ type: 'text', value: str.slice(lastIndex) })
  }
  return segments
}

// DOM 序列化为存储字符串
function serializeFromDOM(el) {
  if (!el) return ''
  let result = ''
  for (const node of el.childNodes) {
    if (node.nodeType === Node.TEXT_NODE) {
      result += node.textContent
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      if (node.dataset?.tagId) {
        result += `{{${node.dataset.tagId}|${node.dataset.tagName}}}`
      } else {
        result += node.textContent || ''
      }
    }
  }
  return result
}

// 创建标签 DOM
function createTagNode(id, name) {
  const span = document.createElement('span')
  span.className = 'mention-tag'
  span.contentEditable = 'false'
  span.dataset.tagId = id
  span.dataset.tagName = name

  const label = document.createElement('span')
  label.className = 'mention-tag__label'
  label.textContent = name
  span.appendChild(label)

  const close = document.createElement('span')
  close.className = 'mention-tag__close'
  close.textContent = '\u00d7'

  const handler = (e) => {
    e.preventDefault()
    e.stopPropagation()
    span.remove()
    emitValue()
  }
  close.addEventListener('mousedown', handler)
  tagClickHandlers.set(close, handler)
  span.appendChild(close)

  return span
}

// 渲染编辑器内容
function renderDOM() {
  const el = editorRef.value
  if (!el) return
  const segments = parseToSegments(props.modelValue)
  el.innerHTML = ''
  segments.forEach(seg => {
    if (seg.type === 'text') {
      el.appendChild(document.createTextNode(seg.value))
    } else {
      el.appendChild(createTagNode(seg.id, seg.name))
    }
  })
}

// 获取光标位置
function getCaretRect() {
  const sel = window.getSelection()
  if (!sel || !sel.rangeCount) return null
  const range = sel.getRangeAt(0).cloneRange()
  range.collapse(true)
  const rect = range.getClientRects()[0]
  if (rect) return rect

  // 兼容空光标位置
  const span = document.createElement('span')
  span.textContent = '\u200b'
  range.insertNode(span)
  const r = span.getBoundingClientRect()
  span.remove()
  sel.removeAllRanges()
  sel.addRange(range)
  return r
}

// 节流更新弹窗位置
const updatePopupPositionThrottle = throttle(() => {
  const rect = getCaretRect()
  const editorRect = editorRef.value?.getBoundingClientRect()
  if (!rect || !editorRect) return
  popupStyle.value = {
    top: `${rect.bottom - editorRect.top + 4}px`,
    left: `${rect.left - editorRect.left}px`
  }
}, 80)

// 关闭弹窗
function closePopup() {
  showPopup.value = false
  searchText.value = ''
  slashInfo.value = null
  selectedIndex.value = 0
}

// 输入事件防抖
const handleInputDebounce = debounce(() => {
  if (isComposing.value) return
  checkSlashTrigger()
  emitValue()
}, 180)

// 中文输入开始
function handleCompositionStart() {
  isComposing.value = true
}

// 中文输入结束
function handleCompositionEnd() {
  isComposing.value = false
  handleInputDebounce()
}

// 检测 / 触发变量面板
function checkSlashTrigger() {
  const sel = window.getSelection()
  if (!sel || !sel.rangeCount) {
    closePopup()
    return
  }
  const range = sel.getRangeAt(0)
  const node = range.startContainer
  if (node.nodeType !== Node.TEXT_NODE) {
    closePopup()
    return
  }
  const text = node.textContent
  const cursor = range.startOffset
  const slashIdx = text.lastIndexOf('/', cursor)
  if (slashIdx === -1 || slashIdx >= cursor) {
    closePopup()
    return
  }
  const between = text.slice(slashIdx + 1, cursor)
  if (/\n/.test(between)) {
    closePopup()
    return
  }
  searchText.value = between
  slashInfo.value = { node, slashOffset: slashIdx }
  selectedIndex.value = 0
  showPopup.value = true
  nextTick(updatePopupPositionThrottle)
}

// 选择变量插入
function selectOption(opt) {
  if (!slashInfo.value || !editorRef.value) return
  const { node, slashOffset } = slashInfo.value
  const sel = window.getSelection()
  const text = node.textContent
  const before = text.slice(0, slashOffset)
  const after = text.slice(sel.getRangeAt(0).startOffset)

  const parent = node.parentNode
  parent.insertBefore(document.createTextNode(before), node)
  parent.insertBefore(createTagNode(opt.id, opt.name), node)
  parent.insertBefore(document.createTextNode(after || '\u00a0'), node)
  parent.removeChild(node)

  closePopup()
  emitValue()
}

// 键盘上下选择
function handleKeydown(e) {
  if (!showPopup.value) return
  const opts = filteredOptions.value
  if (e.key === 'ArrowDown') {
    e.preventDefault()
    selectedIndex.value = (selectedIndex.value + 1) % opts.length
  } else if (e.key === 'ArrowUp') {
    e.preventDefault()
    selectedIndex.value = (selectedIndex.value - 1 + opts.length) % opts.length
  } else if (e.key === 'Enter') {
    e.preventDefault()
    opts.length && selectOption(opts[selectedIndex.value])
  } else if (e.key === 'Escape') {
    e.preventDefault()
    closePopup()
  }
}

function handleFocus() {
  isFocused.value = true
}

function handleBlur() {
  isFocused.value = false
  setTimeout(closePopup, 200)
}

// 粘贴纯文本(替换废弃 API)
function handlePaste(e) {
  e.preventDefault()
  const text = e.clipboardData.getData('text/plain')
  const range = window.getSelection().getRangeAt(0)
  range.deleteContents()
  range.insertNode(document.createTextNode(text))
  emitValue()
}

// 弹窗样式
const popupAbsStyle = computed(() => {
  const rect = editorRef.value?.getBoundingClientRect()
  if (!rect) return {}
  return {
    position: 'fixed',
    top: `${rect.top + parseFloat(popupStyle.value.top || 0)}px`,
    left: `${rect.left + parseFloat(popupStyle.value.left || 0)}px`,
    zIndex: 9999
  }
})

// 双向绑定防死循环
let skipNextWatch = false

function emitValue() {
  skipNextWatch = true
  const val = serializeFromDOM(editorRef.value)
  if (val !== props.modelValue) {
    emit('update:modelValue', val)
  }
  if (!val && editorRef.value) {
    nextTick(() => {
      editorRef.value.innerHTML = ''
    })
  }
}

watch(
  () => props.modelValue,
  (newVal) => {
    if (skipNextWatch) {
      skipNextWatch = false
      return
    }
    const current = serializeFromDOM(editorRef.value)
    if (current !== newVal) {
      renderDOM()
    }
  }
)

onMounted(() => {
  renderDOM()
})

// 销毁事件,避免内存泄漏
onUnmounted(() => {
  tagClickHandlers.forEach((handler, el) => {
    el.removeEventListener('mousedown', handler)
  })
})

defineExpose({
  focus: () => editorRef.value?.focus()
})
</script>

<template>
  <div class="mention-textarea" :class="{ 'is-focused': isFocused, 'is-disabled': disabled }">
    <div
      ref="editorRef"
      class="mention-textarea__editor"
      :contenteditable="!disabled"
      :data-placeholder="placeholder"
      @input="handleInputDebounce"
      @keydown="handleKeydown"
      @focus="handleFocus"
      @blur="handleBlur"
      @paste="handlePaste"
      @compositionstart="handleCompositionStart"
      @compositionend="handleCompositionEnd"
    ></div>

    <Teleport to="body">
      <div v-if="showPopup && filteredOptions.length" class="mention-popup" :style="popupAbsStyle">
        <div
          v-for="(opt, idx) in filteredOptions"
          :key="opt.id"
          class="mention-popup__item"
          :class="{ 'is-active': idx === selectedIndex }"
          @mousedown.prevent="selectOption(opt)"
          @mouseenter="selectedIndex = idx"
        >
          {{ opt.name }}
        </div>
      </div>
    </Teleport>
  </div>
</template>

<style lang="scss" scoped>
.mention-textarea {
  position: relative;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  background: #fff;
  transition: border-color 0.2s;
  width: 100%;

  &.is-focused {
    border-color: #605ce5;
  }

  &.is-disabled {
    background: #f5f7fa;
    cursor: not-allowed;

    .mention-textarea__editor {
      cursor: not-allowed;
      color: #a8abb2;
    }
  }

  &__editor {
    min-height: 60px;
    max-height: 200px;
    overflow-y: auto;
    padding: 5px 14px;
    font-size: 14px;
    line-height: 1.8;
    color: #606266;
    outline: none;
    word-break: break-all;
    white-space: pre-wrap;

    &:empty::before {
      content: attr(data-placeholder);
      color: #c9c9c9;
      pointer-events: none;
    }
  }
}
</style>

<style lang="scss">
.mention-tag {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 0 8px;
  margin: 0 2px;
  height: 22px;
  line-height: 22px;
  background: rgba(96, 92, 229, 0.08);
  border: 1px solid #605ce5;
  border-radius: 4px;
  color: #605ce5;
  font-size: 12px;
  vertical-align: middle;
  user-select: none;
  cursor: default;

  &__label {
    max-width: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  &__close {
    cursor: pointer;
    font-size: 14px;
    line-height: 1;
    margin-left: 2px;
    color: #605ce5;
    opacity: 0.6;

    &:hover {
      opacity: 1;
    }
  }
}

.mention-popup {
  background: #fff;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
  max-height: 200px;
  overflow-y: auto;
  min-width: 150px;

  &__item {
    padding: 8px 12px;
    font-size: 14px;
    color: #606266;
    cursor: pointer;
    transition: background 0.15s;

    &:hover,
    &.is-active {
      background: #f5f4fe;
      color: #605ce5;
    }
  }
}
</style>
相关推荐
Aliex_git2 小时前
Nuxt 学习笔记(一)
前端·笔记·学习
烤麻辣烫2 小时前
json与fastjson
前端·javascript·学习·json
小陈同学呦2 小时前
JavaScript 深浅拷贝详解
前端·javascript
六bring个六2 小时前
opencv简单操作(一)
前端·webpack·node.js
小陈同学呦2 小时前
fetch和axios区别
前端·javascript
森叶2 小时前
Electron 实战:用 utilityProcess 开子进程,去端口化承载协议处理,并由主进程拦截渲染请求后统一中转
前端·javascript·electron
精益数智工坊2 小时前
红牌作战是什么?红牌作战的实施步骤与核心要点
大数据·运维·前端·人工智能·精益工程
techdashen2 小时前
Cloudflare HTML 解析器的十年演化史(一)
前端·html
ZC跨境爬虫2 小时前
移动端爬虫工具Fiddler完整配置流程:PC+安卓模拟器全覆盖,零基础一次配置成功
android·前端·爬虫·测试工具·fiddler