基于 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>
相关推荐
2603_965896621 小时前
Grid 网格布局|CSS 二维页面排版体系
前端·css
有味道的男人1 小时前
搜好货平台关键词商品搜索接口:分页容错与商品数据轻量化提取方案
服务器·前端·javascript
lovingsoft2 小时前
Chrome Lighthouse 详解
前端·chrome
Qlly2 小时前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝2 小时前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦2 小时前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡3 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI3 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95273 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人3 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring