使用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 传递,极大地提高了组件的灵活性和复用性。开发者可以根据具体需求对组件进行定制,而不必改动底层实现。
相关推荐
孤水寒月11 分钟前
基于HTML的悬窗可拖动记事本
前端·css·html
祝余呀20 分钟前
html初学者第一天
前端·html
小马爱打代码1 小时前
微服务外联Feign调用:第三方API调用的负载均衡与容灾实战
微服务·架构·负载均衡
速易达网络3 小时前
RuoYi、Vue CLI 和 uni-app 结合构建跨端全家桶方案
javascript·vue.js·低代码
耶啵奶膘3 小时前
uniapp+firstUI——上传视频组件fui-upload-video
前端·javascript·uni-app
视频砖家3 小时前
移动端Html5播放器按钮变小的问题解决方法
前端·javascript·viewport功能
lyj1689974 小时前
vue-i18n+vscode+vue 多语言使用
前端·vue.js·vscode
小白变怪兽5 小时前
一、react18+项目初始化(vite)
前端·react.js
ai小鬼头5 小时前
AIStarter如何快速部署Stable Diffusion?**新手也能轻松上手的AI绘图
前端·后端·github
9527华安5 小时前
FPGA实现40G网卡NIC,基于PCIE4C+40G/50G Ethernet subsystem架构,提供工程源码和技术支持
fpga开发·架构·网卡·ethernet·nic·40g·pcie4c