使用contenteditable实现富文本输入框

实际效果

缘起:那个蝉鸣午后的求助

"前辈...能帮我看个需求吗?"软糯的声音从身后传来时,我正在调试一个诡异的日期选择器。转身看见新来的前端小妹林小夕,她的睫毛在阳光中扑闪,像极了代码中跳动的光标。

需求文档上写着:"在短信模板编辑器中实现变量插入功能"。这让我想起三年前刚入行时,也被这个看似简单实则暗藏玄机的需求折磨得死去活来。

"我们试试用contenteditable方案吧?"我拖动椅子靠近她的工位,空气中飘来淡淡的栀子花香。小夕如释重负,眼神里透出一丝期待。

技术突围:五个维度的攻防战

2.1 使用contenteditable实现富文本编辑

需求的核心是一个可编辑的输入框,支持普通文本输入,同时允许插入不可编辑的元素。

Vue 3 本身没有提供直接的 contenteditable 绑定机制,因此我们需要手动监听 @input 事件,并解析 innerHTML 以支持富文本。

ini 复制代码
<div 
    ref="editorRef" 
    class="content flex-1" 
    contenteditable 
    @input="handleInput" 
    @paste="handlePaste" 
    @blur="saveSelection"
></div>

handleInput 方法更新输入框字数和同步内容,并在emitUpdate中传递文本内容给父组件保存。

ini 复制代码
// 更新字符统计
const updateCharCount = () => {
  const text = editorRef.value?.textContent || '';
  currentCharCount.value = text.length;
};

// 发送更新事件
const emitUpdate = () => {
  const content = editorRef.value?.textContent?.replace(/\n/g, '') || '';
  emit("update:modelValue", content);
};

// 处理输入事件
const handleInput = () => {
  updateCharCount();
  emitUpdate();
};

"传统的textarea只能处理纯文本,而contenteditable允许我们创建富文本结构。但这也意味着..."我故意停顿,小夕眉宇微蹙,马上补充道:"需要处理更多边界情况!比如粘贴内容时会携带格式...",我们相视一笑,在handlePaste方法中写下防御代码,防止xss攻击,:

typescript 复制代码
const handlePaste = (event: { preventDefault: () => void; clipboardData: any; }) => {
  // 阻止默认行为,即不允许粘贴任何格式的内容
  event.preventDefault();
  // 获取剪贴板数据
  const text = event.clipboardData?.getData("text/plain") || "";
  // 使用document.execCommand('insertText', false, text)插入纯文本
  document.execCommand("insertText", false, text);
};

2.2 光标选区保存与恢复:保障插入位置准确性

当小夕第三次点击插入按钮后变量总是出现在末尾时,她的鼻尖沁出了细小的汗珠。我知道是时候祭出Range和Selection这两个上古神器了。

为了支持变量插入,必须先保存当前的光标位置(即选区 Range 对象),在插入后恢复它,以确保变量能够被正确插入到用户想要的位置。

我们使用 window.getSelection() 获取选区,并在 插入变量 按钮点击时手动恢复它:

ini 复制代码
const saveCaretPosition = () => {
  const selection = window.getSelection();
  if (!selection?.rangeCount) return;
  lastSelection = selection.getRangeAt(0).cloneRange();
  editorRef.value?.focus();
};

然后,在用户选择变量后,我们需要恢复选区,并插入变量:

ini 复制代码
const insertVariable = (varName: string) => {
  if (!varName) return;

  editorRef.value?.focus();
  const selection = window.getSelection();

  if (lastSelection && editorRef.value?.contains(lastSelection.startContainer)) {
    selection!.removeAllRanges();
    selection!.addRange(lastSelection);
  }

  const varElement = createVarElement(varName);
  const range = selection!.getRangeAt(0);
  range.insertNode(varElement);

  requestAnimationFrame(() => {
    const newRange = document.createRange();
    newRange.setStartAfter(varElement);
    newRange.collapse(true);
    selection!.removeAllRanges();
    selection!.addRange(newRange);
    saveCaretPosition();
  });
};

"这样,每次用户点击变量选项后,变量就会被正确地插入到光标所在的位置,而不是默认地出现在输入框的最后。"我语重心长地解释道。

小夕的眸子突然亮起来:"所以blur事件里也要保存选区状态,防止光标意外丢失!" saveSelection 方法是在输入框失焦时保存选区,当插入变量时可以恢复选区

ini 复制代码
// 常规光标位置保存(用于blur事件)
const saveSelection = () => {
  const selection = window.getSelection();
  if (selection?.rangeCount) {
    const range = selection.getRangeAt(0);
    if (editorRef.value?.contains(range.commonAncestorContainer)) {
      lastSelection = range.cloneRange();
    }
  }
};

2.3 变量元素DOM设计:不可编辑区块封装

为了让变量不可编辑,同时保证它可以整体删除,我们创建 span 标签,并设置 contentEditable=false

ini 复制代码
const createVarElement = (varName: string) => {
  const span = document.createElement("span");
  span.className = "variable";
  span.contentEditable = "false";
  span.dataset.var = varName;
  span.textContent = `${${varName}}`;
  return span;
};

CSS 层面,我们为变量设置了独特的样式,让它与普通文本明显区分开来:

复制代码
scss
复制编辑
css 复制代码
.variable {
  background-color: #f0f2f5;
  border-radius: 3px;
  padding: 0 4px;
  color: #409eff;
  display: inline-block;
}

这样,用户就能清晰地区分哪些内容是变量,哪些是普通文本。


2.4 双向数据绑定:实现内容同步

为了在 Vue 组件中正确存储和展示变量,我们需要在 onMounted 时解析 modelValue,并将 ${变量} 转换成变量 span 元素。

scss 复制代码
const initHtml = (value: string) => {
  return value.replace(/${(\w+)}/g, (_, varName) => {
    return createVarElement(varName).outerHTML;
  });
};

onMounted(() => {
  editorRef.value!.innerHTML = initHtml(props.modelValue);
  updateCharCount();
});

同时,我们监听 modelValue 的变化,确保内容一致:

ini 复制代码
watch(() => props.modelValue, (newVal) => {
  const parsedHTML = initHtml(newVal);
  if (parsedHTML !== editorRef.value?.innerHTML) {
    editorRef.value!.innerHTML = parsedHTML;
    updateCharCount();
  }
});

这样,无论是用户手动输入,还是外部数据更新,文本内容都会保持一致。


2.5 配置式参数传递:组件灵活集成

为了让组件更加灵活,我们定义了 variableList 作为变量选项的来源,用户可以自由配置:

php 复制代码
const props = defineProps({
  variableList: {
    type: Array<{ label: string; value: string }>,
    default: () => [],
  },
});

"这样我们只需要传递variableList..."她的声音带着疲惫的雀跃。我注意到她的马克杯边缘印着淡淡的唇印,咖啡早已凉透。小夕看着最终完成的组件,脸上露出了惊喜的笑容。她说道:"谢谢前辈,真的太厉害了!"


技术总结

整个短信模板编辑器的实现,正是建立在以下几个关键技术点上的:

  1. contenteditable 实现富文本编辑
    利用原生 contenteditable 属性,结合 @input@paste 等事件,实现了一个灵活而安全的富文本输入框,超越了传统 textarea 的局限。
  2. 光标选区保存与恢复机制
    通过 RangeSelection 对象,我们不仅保存了用户的光标位置,还能在插入变量时恢复选区,确保变量精确地嵌入到用户期望的位置。这一机制极大提升了用户体验,避免了频繁的编辑失误。
  3. 变量元素 DOM 设计
    采用不可编辑的 span 标签封装变量,并通过 CSS 样式做视觉区分,使得变量在编辑过程中既能保持整体性,又能被整体删除。这种设计兼顾了用户交互和数据完整性。
  4. 双向数据绑定实现内容同步
    借助 Vue 的响应式机制,编辑器的内容和外部数据始终保持同步,无论是初始化还是动态更新,都能确保数据的一致性,方便后续处理与存储。
  5. 配置式参数传递实现组件灵活集成
    将变量列表等配置参数作为 Props 传递,极大地提高了组件的灵活性和复用性。开发者可以根据具体需求对组件进行定制,而不必改动底层实现。
相关推荐
佩奇的技术笔记4 小时前
Java学习手册:单体架构到微服务演进
java·微服务·架构
GalenWu4 小时前
对象转换为 JSON 字符串(或反向解析)
前端·javascript·微信小程序·json
小猪写代码4 小时前
分布式处理架构
分布式·架构
GUIQU.4 小时前
【Vue】微前端架构与Vue(qiankun、Micro-App)
前端·vue.js·架构
数据潜水员4 小时前
插槽、生命周期
前端·javascript·vue.js
2401_837088505 小时前
CSS vertical-align
前端·html
优雅永不过时·5 小时前
实现一个漂亮的Three.js 扫光地面 圆形贴图扫光
前端·javascript·智慧城市·three.js·贴图·shader
慧一居士5 小时前
Docker Compose 的详细使用总结、常用命令及配置示例
容器·架构
揣晓丹5 小时前
JAVA实战开源项目:健身房管理系统 (Vue+SpringBoot) 附源码
java·vue.js·spring boot·后端·开源
杰克逊的日记6 小时前
运维体系架构规划
运维·架构