如何使用contenteditable实现富文本输入框支持插入变量

前端 潘老师 1个月前 (03-19) 76 ℃ (0) 扫码查看

最近遇到个挺有意思的开发需求,今天来给大伙说说。在开发短信模板编辑器的时候,得实现一个变量插入功能,也就是在输入框里能插入变量,而且还得是富文本输入框,支持各种格式的编辑。这听起来有点复杂,不过咱们一步步拆解,其实也没那么难。

一、需求的由来

那天我正捣鼓一个日期选择器的bug呢,公司新来的前端小妹林小夕找我帮忙,说有个需求搞不定。我一看,是在短信模板编辑器里实现变量插入功能。嘿,这可太熟悉了,我刚入行那会,也被这需求折磨得够呛。想着这需求虽然看着简单,但里面弯弯绕绕还挺多的,就跟小夕说,咱们试试用contenteditable这个方案来实现吧。

实现效果

二、技术实现过程

(一)用contenteditable实现富文本编辑

这个需求的关键就是得有个能编辑的输入框,既能输入普通文本,还得能插入不可编辑的元素。但Vue 3本身没有直接绑定contenteditable的机制,所以得手动监听@input事件,再解析innerHTML,才能实现富文本编辑。

咱们先在模板里定义好这个输入框:

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

这里给div加上了contenteditable属性,让它变成可编辑的。同时监听了input、paste和blur这几个事件,每个事件都有不同的作用。

接着看看处理输入事件的代码:

// 更新字符统计
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();
};

在handleInput方法里,调用了updateCharCount函数更新输入框的字数统计,再通过emitUpdate把输入框的文本内容传递给父组件保存起来。

还有处理粘贴事件的代码也很重要,因为普通的粘贴可能会带来格式问题,甚至有XSS攻击的风险:

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);
};

在handlePaste方法里,先阻止了默认的粘贴行为,然后获取剪贴板里的纯文本内容,再用document.execCommand把纯文本插入到输入框里,这样就能保证插入的内容安全又规范。

(二)光标选区保存与恢复

小夕在测试的时候发现,每次点击插入按钮,变量老是出现在输入框末尾,这可不行。这时候就得靠Range和Selection这两个“神器”来解决问题了。

为了能把变量准确插入到用户想要的位置,得先保存当前的光标位置,也就是选区Range对象,等插入完变量再恢复它。

先看看保存光标的代码:

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

在saveCaretPosition函数里,通过window.getSelection获取当前的选区,如果有选区,就克隆一份保存起来,方便后续恢复。

再看看插入变量的代码:

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();
  });
};

在insertVariable函数里,先判断变量名是否存在,存在的话就获取选区。如果之前保存过选区,就先把当前选区清空,再恢复之前保存的选区,然后创建变量元素插入到选区里。插入完之后,还得重新设置选区,把光标定位到变量后面,最后再保存一下新的光标位置。

还有在输入框失焦的时候,也得保存选区状态,防止下次插入变量时光标位置不对:

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

(三)变量元素DOM设计

为了让变量不能被编辑,还能整体删除,咱们创建一个span标签,把变量包起来,然后设置contentEditable=false。

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

在这个createVarElement函数里,创建了一个span标签,给它加上了variable类名,设置不可编辑,还在dataset里存了变量名,最后把变量的显示内容设置好。

光有标签还不够,还得在CSS里给变量设置个独特的样式,和普通文本区分开:

.variable {
  background-color: #f0f2f5;
  border-radius: 3px;
  padding: 0 4px;
  color: #409eff;
  display: inline-block;
}

这样,用户在编辑的时候就能很清楚地知道哪些是变量,哪些是普通文本了。

(四)双向数据绑定

在Vue组件里,为了能正确存储和展示变量,咱们得在组件挂载的时候解析modelValue,把变量转换成span元素。

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

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

在initHtml函数里,用正则表达式把字符串里的变量替换成对应的span元素。在onMounted钩子函数里,把解析后的内容设置到输入框里,再更新一下字数统计。

除了初始化,还得监听modelValue的变化,保证输入框内容和外部数据始终一致:

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

在这个watch函数里,只要modelValue有变化,就重新解析内容,如果和输入框当前的innerHTML不一样,就更新输入框内容,同时更新字数统计。

(五)配置式参数传递

为了让这个组件用起来更灵活,咱们定义了一个variableList,用来配置变量选项,用户可以根据自己的需求自由设置。

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

这样,在使用这个组件的时候,只需要传递不同的variableList,就能轻松切换变量选项了。

三、总结

折腾了这么一大圈,这个短信模板编辑器的富文本输入框功能总算是实现了。这里面用到的几个技术点还挺关键的:

  • 利用contenteditable实现富文本编辑:借助这个属性,再配合一些事件监听,咱们就打造出了一个比传统textarea功能更强大的富文本输入框,能满足各种编辑需求。
  • 光标选区保存与恢复机制:靠Range和Selection对象,成功解决了变量插入位置不对的问题,大大提升了用户体验,编辑起来更顺手。
  • 变量元素DOM设计:用不可编辑的span标签封装变量,再加上独特的CSS样式,让变量在编辑过程中既不会被误改,又能方便地整体删除。
  • 双向数据绑定实现内容同步:通过Vue的响应式机制,不管是初始化还是数据更新,输入框内容和外部数据都能保持一致,后续处理和存储数据也更方便。
  • 配置式参数传递实现组件灵活集成:把变量列表这些配置参数通过Props传递,组件复用性大大提高,以后遇到类似需求,稍微改改配置就能用,开发效率杠杠的!

大伙要是在开发中也遇到类似需求,不妨参考参考这篇文章,希望能帮到你们!


版权声明:本站文章,如无说明,均为本站原创,转载请注明文章来源。如有侵权,请联系博主删除。
本文链接:https://www.panziye.com/front/15998.html
喜欢 (0)
请潘老师喝杯Coffee吧!】
分享 (0)
用户头像
发表我的评论
取消评论
表情 贴图 签到 代码

Hi,您需要填写昵称和邮箱!

  • 昵称【必填】
  • 邮箱【必填】
  • 网址【可选】