基于Div contenteditable 属性 实现一个 “只读” 标签编辑器

记录一个开发遇到需求:输入框不允许用户随意输入文本,只能插入特定的"标签"或"变量" ,并且这些标签需要支持删除操作。

普通的inputtextarea 无法实现标签样式的嵌入,而富文本编辑器(插件)又过于厚重。所以想到利用 HTML 的 contenteditable 属性,手撸一个轻量级、不支持直接键入、但支持光标插入的标签编辑器。

🎯 核心功能与需求

  1. 禁止直接输入:用户按键盘字符键无效,不能输入普通文本。
  2. 支持标签插入:通过外部触发或特定逻辑插入可视化的标签(DOM 节点)。
  3. 光标控制:虽然不能打字,但光标需要能定位,以便在两个标签中间插入新标签。
  4. 删除功能:支持点击标签内的"×"号删除,或通过Backspace 删除(代码中主要演示点击删除)。
  5. 数据双向绑定:标签的变化需要同步更新到 Vue 的 model 数据中。

🤠 先上效果

🛠️ 实现思路

利用 div 的 contenteditable="true"。实现可以自定样式标签插入, 但为了控制输入,我们需要监听一系列事件:

html 复制代码
<template>
  <div
    ref="inputBoxRef"
    contenteditable="true"
    id="editor-box"
    placeholder="请插入"
    @beforeInput="onBeforeInput"
    @keydown="handleKeydown"
    @compositionstart="onCompositionStart"
    @compositionend="onCompositionEnd"
    @paste.prevent></div>
</template>
如何"禁止输入"但"允许插入"?

🚫 拦截常规输入

我们通过 onBeforeInputhandleKeydown 阻止默认行为:

typescript 复制代码
// 禁止直接输入文本
const onBeforeInput = (e: InputEvent) => {
  e.preventDefault();
};

// 阻止回车换行等键盘事件
const handleKeydown = (e: KeyboardEvent) => {
  e.preventDefault();
};

🚫 拦截组合输入

通过onCompositionStartonCompositionEnd 阻止输入法软件键入问题;

typescript 复制代码
const onCompositionStart = () => {
  isComposing.value = true;
};

// 处理组合输入结束
const onCompositionEnd = () => {
  isComposing.value = false;
  if (!inputBoxRef.value) return;
  clearValue();
  $matchList.value.forEach((i) => insertMatch(i.matchLabel));
};
✨ 魔法:零宽空格 (ZWSP)

既然禁止了输入,光标在两个标签 之间往往会"无法停留"或者被浏览器怪异的行为吞噬。为了解决这个问题,引入了 零宽空格 (\u200B)

在插入标签时,采用 ZWSP + 标签 + ZWSP 的三明治结构:

typescript 复制代码
// 插入零宽空格的辅助函数
const createZwsp = () => document.createTextNode('\u200B');
  • 前置 ZWSP:确保标签前面有一个可以放置光标的文本节点。
  • 后置 ZWSP:确保标签后面有一个可以放置光标的文本节点,且插入完成后,光标能跳到这个位置。
核心插入函数
typescript 复制代码
const insertMatch = (character: string) => {
  const inputBox = inputBoxRef.value;
  if (!inputBox) return;

  const selection = window.getSelection();
  // 如果没有选区,或者选区不在输入框内,则强制聚焦到末尾
  ... 
  ...
  ... 省略
  
  let range = selection.getRangeAt(0);
  range.deleteContents(); // 如果有选中文字,先删除

  // 1. 准备"三明治"结构
  const zwspBefore = createZwsp();
  const zwspAfter = createZwsp();
  
  // 2. 创建标签 DOM
  const matchDiv = document.createElement('span');
  matchDiv.className = 'matchItem';
  matchDiv.contentEditable = 'false'; // 关键:标签本身作为一个整体,不可编辑内部文字
  matchDiv.dataset.character = character; 
  matchDiv.innerHTML = `${character}<span class="remove-btn">×</span>`;

  // 3. 绑定删除事件(闭包处理)
  matchDiv.querySelector('.remove-btn')?.addEventListener('click', (e) => {
    e.stopPropagation();
    // 更新数据源逻辑...
  });

  // 4. 使用 DocumentFragment 插入 DOM
  const frag = document.createDocumentFragment();
  frag.appendChild(zwspBefore);
  frag.appendChild(matchDiv);
  frag.appendChild(zwspAfter);
  range.insertNode(frag);

  // 5. 修正光标位置:移动到后置 ZWSP 后面
  range.setStartAfter(zwspAfter);
  range.setEndAfter(zwspAfter);
  selection.removeAllRanges();
  selection.addRange(range);
  
  // 滚动到底部
  inputBox.scrollTop = inputBox.scrollHeight;
};

样式处理 (SCSS)

为了让标签看起来像"标签",并且解决 contenteditable 的一些默认怪异样式,需调整 CSS。

  • user-select: none: 防止用户不小心选中标签内的文字(这通常会导致删除时只删了一半标签)。
  • vertical-align: middle: 保证标签和零宽空格(虽然看不见)在垂直方向居中。
  • :empty::before: 纯 CSS 实现 placeholder 效果。
css 复制代码
#editor-box {
  white-space: pre-wrap; // 保留空白符
  word-break: break-all;
  line-height: 2; // 给标签留足垂直空间
  
  // 纯 CSS 实现 Placeholder
  &:empty::before {
    content: attr(placeholder);
    color: #aaa;
    pointer-events: none;
    display: block; 
  }
}

.matchItem {
  display: inline-flex; // 使用 flex 布局方便对齐
  user-select: none;    // 禁止选中内部文本
  contenteditable: false; // 再次确保不可编辑
  // ...其他美化样式
}

⚠️ 遇到的坑与注意事项

1、中文输入法 (IME):

代码中监听了 compositionstart 和 compositionend 。这是为了处理用户如果不小心触发了输入法,在输入法选词结束时(compositionend),代码逻辑选择了清空当前输入并重新渲染列表。这是为了防止输入法残留文字破坏 DOM 结构。
2、光标丢失:

如果你直接操作 innerHTML(例如 inputBox.innerHTML += ...) ,光标会重置到最前面。这也是为什么我们必须使用 Range API 来插入节点,它能在修改 DOM 后保留光标位置。
3、数据同步:

由于这是一个非受控组件(DOM 操作为主),我们需要手动维护 matchList 数组。当点击标签内的 × 时,需要根据 dataset 找到对应的数据并剔除

完整源码

javascript 复制代码
<template>
  <div
    ref="inputBoxRef"
    contenteditable="true"
    id="editor-box"
    placeholder="请插入"
    class="dark:!border-dark-border"
    @beforeInput="onBeforeInput"
    @keydown="handleKeydown"
    @compositionstart="onCompositionStart"
    @compositionend="onCompositionEnd"
    @paste.prevent></div>
</template>

<script lang="ts" setup>
import {  ref } from 'vue';

const emit = defineEmits(['updateMatchList']);

const $matchList = defineModel<{ matchLabel: string; id?: string | number }[]>('matchList', {
  default: [],
});

const inputBoxRef = ref<HTMLElement | null>(null);

// 组合输入(处理中文输入法键入)
const isComposing = ref(false);

const onCompositionStart = () => {
  isComposing.value = true;
};

// 处理组合输入结束
const onCompositionEnd = () => {
  isComposing.value = false;
  if (!inputBoxRef.value) return;
  clearValue();
  $matchList.value.forEach((i) => insertMatch(i.matchLabel));
};

// 禁止直接输入文本
const onBeforeInput = (e: InputEvent) => {
  e.preventDefault();
};

// 处理键盘事件 阻止手动换行
const handleKeydown = (e: KeyboardEvent) => {
  e.preventDefault();
};

// 插入零宽空格
const createZwsp = () => document.createTextNode('\u200B');

const insertMatch = (character: string) => {
  const inputBox = inputBoxRef.value;
  if (!inputBox) return;

  const selection = window.getSelection();
  if (!selection || selection.rangeCount === 0) return;

  let range = selection.getRangeAt(0);

  // 确保 range 在输入框内
  if (!inputBox.contains(range.commonAncestorContainer)) {
    inputBox.focus();
    range = document.createRange();
    range.selectNodeContents(inputBox);
    range.collapse(false); // 移动到末尾
  }

  // 删除选区内容(如果有选中)
  range.deleteContents();

  // 前置 ZWSP 确保前面有光标位
  const zwspBefore = createZwsp();
  // 后置 ZWSP 确保后面有光标位
  const zwspAfter = createZwsp();

  // 创建标签 DOM
  const matchDiv = document.createElement('span');
  matchDiv.className = 'matchItem';
  matchDiv.contentEditable = 'false'; // 禁止光标进入内部
  matchDiv.dataset.character = character; // 自定属性存储匹配项label
  matchDiv.innerHTML = `${character}<span class="remove-btn">×</span>`;

  // 绑定删除事件
  matchDiv.querySelector('.remove-btn')?.addEventListener('click', (e) => {
    e.stopPropagation();
    const matchItemText = matchDiv.dataset.character;

    // 对应删除匹配项列表数据 (注意 实际删除考虑使用唯一属性判断索引)
    const index = $matchList.value.findIndex((i) => i.matchLabel === matchItemText);
    if (index !== -1) $matchList.value.splice(index, 1);
    emit('updateMatchList');
  });

  //  插入 DOM
  const frag = document.createDocumentFragment();
  frag.appendChild(zwspBefore);
  frag.appendChild(matchDiv);
  frag.appendChild(zwspAfter);

  range.insertNode(frag);

  //  修正光标位置:移动到后置 ZWSP 后面
  range.setStartAfter(zwspAfter);
  range.setEndAfter(zwspAfter);

  selection.removeAllRanges();
  selection.addRange(range);

  inputBox.scrollTop = inputBox.scrollHeight;
};

// 清空
const clearValue = () => {
  if (inputBoxRef.value) inputBoxRef.value.innerHTML = '';
};

// 暴露方法
defineExpose({
  insertMatch,
  clearValue,
});
</script>

<style lang="scss" scoped>
#editor-box {
  display: block;
  padding: 8px 10px;
  min-height: 100px;
  max-height: 160px;
  overflow-y: auto;
  white-space: pre-wrap;
  word-break: break-all;
  line-height: 2; /* 增加行高,防止标签垂直重叠太挤 */
  font-size: 14px;

  border: 1px solid #e0e5eb;
  border-radius: 4px;
  color: #333;
  caret-color: #000;

  &:focus {
    outline: none;
  }

  &:empty::before {
    content: attr(placeholder);
    color: #aaa;
    pointer-events: none;
    display: block; /* 确保 placeholder 占位 */
  }
}
</style>

<style lang="scss">
.matchItem {
  display: inline-flex;
  align-items: center;
  vertical-align: middle;
  margin: 0 4px 4px;
  padding: 2px 8px;
  background-color: #f0f7ff;
  border: 1px solid #cce0ff;
  border-radius: 4px;
  color: #337ecc;
  font-size: 12px;
  user-select: none; /*禁止光标进入*/
  -webkit-user-select: none;

  .remove-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    margin-left: 6px;
    width: 14px;
    height: 14px;
    border-radius: 50%;
    cursor: pointer;
    color: #fff;
    background-color: #aaccff;
    font-size: 12px;
    line-height: 1;

    &:hover {
      background-color: #ff6b6b;
    }
  }
}
</style>

如有更好思路请告诉我😎

相关推荐
JIngJaneIL2 小时前
远程在线诊疗|在线诊疗|基于java和小程序的在线诊疗系统小程序设计与实现(源码+数据库+文档)
java·数据库·vue.js·spring boot·小程序·毕设·在线诊疗小程序
zyplayer-doc3 小时前
重写OFD查看器,完善PDF查看器,增加搜索历史记录、滚动分页、目录排序等,zyplayer-doc 2.5.7 发布啦!
pdf·编辑器·飞书·开源软件·创业创新·有道云笔记
笙年5 小时前
Vue 作用域插槽
前端·javascript·vue.js
咯哦哦哦哦5 小时前
linux patchelf工具 用法
linux·vscode·编辑器·gcc
鱼锦0.06 小时前
基于spring+vue把图片文件上传至阿里云oss容器并回显
java·vue.js·spring
zeijiershuai6 小时前
Vue 工程化、ElementPlus 快速入门、ElementPlus 常见组件-表格组件、ElementPlus常见组件-分页条组件
前端·javascript·vue.js
Molesidy7 小时前
【VSCode】使用 VSCode + EIDE插件 的开发STM32的超详细教程
ide·vscode·stm32·编辑器·eide
洛克大航海7 小时前
安装 Visual Studio Code 及其插件用于前端开发
ide·vscode·编辑器