使用contenteditable实现富文本输入框

895 阅读8分钟

image.png

实际效果

image.png

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

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

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

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

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

2.1 使用contenteditable实现富文本编辑

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

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

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

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

// 更新字符统计
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攻击,:

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() 获取选区,并在 插入变量 按钮点击时手动恢复它:

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

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

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 方法是在输入框失焦时保存选区,当插入变量时可以恢复选区

// 常规光标位置保存(用于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

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 层面,我们为变量设置了独特的样式,让它与普通文本明显区分开来:

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

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


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

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

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

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

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

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

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


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

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

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 传递,极大地提高了组件的灵活性和复用性。开发者可以根据具体需求对组件进行定制,而不必改动底层实现。

组件完整代码

请注意组件不能直接使用,样式使用了unocss,个别组件基于element-plus进行了一些二次封装

<template>
  <div class="flex gap-[6px] w-full">
    <div ref="editorRef" class="content flex-1" contenteditable @input="handleInput" @paste="handlePaste"
      @blur="saveSelection"></div>
    <span class="counter">{{ currentCharCount }}/{{ maxChars }}</span>
    <el-popover popper-class="variable-select-popper" :width="200" trigger="hover">
      <template #default>
        <el-select :teleported="false" filterable placeholder="输入变量名筛选" :suffix-icon="Search" @change="insertVariable">
          <el-option v-for="(item, index) in props.variableList" :key="index" :label="item.label" :value="item.value" />
        </el-select>
      </template>
      <template #reference>
        <CommonButton size="small" type="primary" @click="saveCaretPosition">插入变量</CommonButton>
      </template>
    </el-popover>
  </div>
</template>

<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { CommonButton } from "tutor-pro-ui-vue";
import { Search } from "@element-plus/icons-vue";

const props = defineProps({
  modelValue: {
    type: String,
    default: "",
  },
  variableList: {
    type: Array<{ label: string; value: string }>,
    default: () => [],
  },
  maxChars: {
    type: Number,
    default: 200,
  },
});

const emit = defineEmits(["update:modelValue"]);
const editorRef = ref<HTMLElement>();
const currentCharCount = ref(0);
// 保存最后一次有效选区
let lastSelection: Range | undefined = undefined;

/**
 * @description: 创建变量元素
 * @param {*} varName 变量名
 * @return {*} 创建的span元素
 */
const createVarElement = (varName: string) => {
  const span = document.createElement("span");
  span.className = "variable";
  span.contentEditable = "false"; // 不可编辑
  span.dataset.var = varName;
  span.textContent = `\${${varName}}`;
  return span;
};

/**
 * 保存当前光标位置(专为插入变量按钮点击设计)
 */
const saveCaretPosition = () => {
  const selection = window.getSelection();
  if (!selection?.rangeCount) return;

  // 保存有效选区
  lastSelection = selection.getRangeAt(0).cloneRange();
  editorRef.value?.focus();
};

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

/**
 * @description: 插入变量
 * @param {*} varName 变量名
 * @return {*} 无
 */
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);
    saveSelection(); // 保存新的光标位置
  });

  updateCharCount();
  emitUpdate();
};

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

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

/**
 * 初始化内容时转换变量标签
 * @param value 原始文本
 * @returns 转换后的HTML字符串
 */
const initHtml = (value: string) => {
  return value.replace(/\$\{(\w+)\}/g, (_, varName) => {
    return createVarElement(varName).outerHTML;
  });
};

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

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

<style lang="scss" scoped>
.content {
  min-height: 100px;
  border: 1px solid #ddd;
  padding: 8px;
  line-height: 1rem;
  position: relative;
  word-wrap: break-word;
  overflow-y: auto;
  border-radius: 0.25rem;
  resize: both;

  &:focus {
    outline: 1px solid #409eff;
  }
}

.counter {
  position: absolute;
  right: 6px;
  bottom: 0px;
  color: #999;
  font-size: 12px;
}

:deep(.variable) {
  background-color: #f0f2f5;
  border-radius: 3px;
  padding: 0 4px;
  margin: 0 2px 4px;
  color: #409eff;
  display: inline-block;
}
</style>

使用示例

<form-item :rules="[{ required: true, trigger: 'blur', validator: validatorSms }]">
   <template #label>
       <div class="line-height-[20px]">
            <span>模板内容</span>
            <br>
            <span class="text-[#999] text-[12px]">(短信内容)</span>
       </div>
   </template>

   <ComTextArea v-model="formState.smsContent" :variableList="variableList"></ComTextArea>
</form-item>

<script setup lang="ts">
  const variableList = [
      { label: '变量名1', value: 'variableName1' },
      { label: '变量名2', value: 'variableName2' },
      { label: '变量名3', value: 'variableName3' }
  ]
  const validatorSms = (_rule: any, _value: any, callback: any) => {
      if (!formState.smsContent) {
        callback(new Error('请输入模板内容'));
      } else if (formState.smsContent.length > 200) {
          callback(new Error('模板内容不能超过200个字符'));
      } else {
          callback();
      }
  }
</script>