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

记录一个开发遇到需求:输入框不允许用户随意输入文本,只能插入特定的“标签”或“变量”,并且这些标签需要支持删除操作。
普通的 input textarea 无法实现标签样式的嵌入,而富文本编辑器(插件)又过于厚重。所以想到利用 HTML 的 contenteditable 属性,手撸一个轻量级、不支持直接键入、但支持光标插入的标签编辑器。

核心功能与需求

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

先上效果

在这里插入图片描述

️ 实现思路

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

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

拦截常规输入
我们通过 onBeforeInputhandleKeydown 阻止默认行为:

// 禁止直接输入文本
const onBeforeInput = (e: InputEvent) => {
e.preventDefault();
};
// 阻止回车换行等键盘事件
const handleKeydown = (e: KeyboardEvent) => {
e.preventDefault();
};

拦截组合输入
通过onCompositionStartonCompositionEnd 阻止输入法软件键入问题;

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 的三明治结构:

// 插入零宽空格的辅助函数
const createZwsp = () => document.createTextNode('\u200B');
  • 前置 ZWSP:确保标签前面有一个可以放置光标的文本节点。
  • 后置 ZWSP:确保标签后面有一个可以放置光标的文本节点,且插入完成后,光标能跳到这个位置。
核心插入函数
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 效果。
#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 找到对应的数据并剔除

完整源码

<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>

如有更好思路请告诉我

posted on 2026-02-04 10:09  ljbguanli  阅读(112)  评论(0)    收藏  举报