vue3实现输入框标签和文本交互

交互内容1 ,输入插入标签和输入文字

交互2,删除文本时删除到对应的标签,标签全部删除

交互3,导出清空全部内容和删除对应标签的方法

交互4,支持props传入文本和标签展示

标签判断:双花括号{{ }}作为判断依据,这里也可以修改优化为其他判断依据

直接看代码

<template>

<div class="tag-input-container">

<div

ref="editorRef"

class="editor"

contenteditable="true"

@input="handleInput"

@keydown="handleKeyDown"

v-html="initialHTML"

spellcheck="false"

></div>

</div>

</template>

<script setup>

import { ref, onMounted, nextTick } from 'vue';

const props = defineProps({

modelValue: { type: String, default: '' },

allowedTags: { type: Array, default: () => [] }

});

const emit = defineEmits(['update:modelValue', 'change']);

const editorRef = ref(null);

const initialHTML = ref('');

let lastSelectionRange = null;

onMounted(() => {

// 初始化时,将文本转为 HTML。注意:在 pre-wrap 模式下,文本中的 \n 会自动换行

initialHTML.value = parseTextToHTML(props.modelValue);

nextTick(() => updateOutput());

});

// 解析文本:只处理标签,保留原有的空格和换行

const parseTextToHTML = (text) => {

if (!text) return '';

const regex = /\{\{([^}]+)\}\}/g;

return text.replace(regex, (match, tagName) => {

if (props.allowedTags.includes(tagName)) {

return `<span class="blue-tag" contenteditable="false" data-tag="{tagName}"\>{{{tagName}}}</span>`;

}

return match;

});

};

// --- 核心:手动递归提取文本 ---

const getEditorContent = (node) => {

let content = "";

const children = node.childNodes;

for (let i = 0; i < children.length; i++) {

const child = children[i];

if (child.nodeType === Node.TEXT_NODE) {

// 1. 处理文本节点

content += child.nodeValue;

} else if (child.nodeType === Node.ELEMENT_NODE) {

// 2. 处理标签节点

if (child.classList.contains('blue-tag')) {

content += `{{${child.dataset.tag}}}`;

} else if (child.tagName === 'BR') {

// 3. 处理显式换行

content += '\n';

} else if (child.tagName === 'DIV' || child.tagName === 'P') {

// 4. 处理块级容器(回车产生的 div)

const childText = getEditorContent(child);

if (childText) {

// 如果当前内容末尾还没有换行,且这不是第一个节点,则补换行

if (content.length > 0 && !content.endsWith('\n')) {

content += '\n';

}

content += childText;

} else {

// 处理空行(<div><br></div> 情况)

content += '\n';

}

} else {

// 其他容器(如 span, b, i 等)递归处理

content += getEditorContent(child);

}

}

}

return content;

};

const updateOutput = () => {

if (!editorRef.value) return;

const content = getEditorContent(editorRef.value);

// 获取当前存在的标签列表

const tags = [];

editorRef.value.querySelectorAll('.blue-tag').forEach(el => {

tags.push(el.dataset.tag);

});

// 这里的 replace(/\n\n/g, '\n') 取决于你是否想合并浏览器产生的双重换行

// 正常情况下直接输出即可

const finalContent = content;

emit('update:modelValue', finalContent);

emit('change', {

tag: tags,

content: finalContent

});

};

const handleInput = () => {

updateOutput();

};

const handleKeyDown = (e) => {

// 处理回车后的同步

if (e.key === 'Enter') {

setTimeout(updateOutput, 0);

}

};

// --- 外部方法 ---

const insertTag = (tagName) => {

editorRef.value.focus();

const sel = window.getSelection();

if (lastSelectionRange) {

sel.removeAllRanges();

sel.addRange(lastSelectionRange);

}

const tagNode = document.createElement('span');

tagNode.className = 'blue-tag';

tagNode.contentEditable = 'false';

tagNode.dataset.tag = tagName;

tagNode.innerText = `{{${tagName}}}`;

if (lastSelectionRange) {

lastSelectionRange.insertNode(tagNode);

lastSelectionRange.setStartAfter(tagNode);

lastSelectionRange.collapse(true);

sel.removeAllRanges();

sel.addRange(lastSelectionRange);

} else {

editorRef.value.appendChild(tagNode);

}

updateOutput();

};

const saveSelection = () => {

const sel = window.getSelection();

if (sel.rangeCount > 0) {

const range = sel.getRangeAt(0);

if (editorRef.value.contains(range.commonAncestorContainer)) {

lastSelectionRange = range;

}

}

};

document.addEventListener('selectionchange', () => {

if (editorRef.value) saveSelection();

});

// 清楚所有标签

const clearContent = () => {

editorRef.value.innerHTML = initialHTML.value;

updateOutput();

initialHTML.value = '';

};

// --- 外部调用:删除特定标签 ---

const removeTagByName = (tagName) => {

const tags = editorRef.value.querySelectorAll(`.blue-tag[data-tag="${tagName}"]`);

tags.forEach(el => el.remove());

updateOutput();

};

defineExpose({ insertTag,clearContent,removeTagByName });

</script>

<style scoped>

.editor {

border: 1px solid #ccc;

min-height: 150px;

padding: 12px;

border-radius: 4px;

line-height: 1.5;

outline: none;

text-align: left;

/* 核心设置:保留所有空格和换行,同时允许自动换行防止溢出 */

white-space: pre-wrap;

word-break: break-all;

overflow-wrap: break-word;

}

.editor:focus {

border-color: #409eff;

}

:deep(.blue-tag) {

display: inline-block;

background-color: #ecf5ff;

color: #409eff;

border: 1px solid #d9ecff;

padding: 0 6px;

margin: 0 2px;

border-radius: 4px;

user-select: none;

font-size: 14px;

vertical-align: baseline;

white-space: nowrap; /* 标签内部不换行 */

}

</style>

组件调用

复制代码
<button @click=addTag('标签内容')>新增</button>
<button @click=deleteTag('标签内容')>删除一个标签</button>
<button @click=deleteAllContent>清空全部</button>    
            <Editor 
                  ref="tagInputRef"
                  v-model="push_content"
                  :allowedTags="tags"
                  @change="handleDataChange"
                />
                 <!-- 结果展示 -->
                <div style="margin-top: 20px; background: #f5f5f5; padding: 10px;">
                  <p><strong>实时输出结构:</strong></p>
                  <pre>{{ outputData }}</pre>
                </div>

js
const tagInputRef = ref(null) as any
const push_content = ref<string>('asdakdj')
const tags = ref<string[]>(['标签1','标签2'])
const outputData = ref({ tag: [], content: '' });
const selectedTags = ref([]); //选中并插入的标签
const handleDataChange = (data) => {
  outputData.value = data;
  selectedTags.value = data.tag;
};
//添加标签
const addTag = (name) => {
  tagInputRef.value.insertTag(name);
};
//删除标签
const deleteTag = (name) => {
  tagInputRef.value.removeTagByName(name);
};
//删除全部内容
const deleteAllContent = ()=>{
    tagInputRef.value.clearContent();
}
相关推荐
ZC跨境爬虫1 小时前
跟着MDN学HTML_day_47:(Document接口)
前端·javascript·ui·html·ecmascript·音视频
sheeta19981 小时前
vue_vuex笔记
javascript·vue.js·笔记
前端 贾公子1 小时前
从零开始:使用Node.js和Cheerio进行轻量级网页数据提取
前端·vue.js
user297525876122 小时前
使用SSE实现流式渲染实践
前端·javascript
LPieces2 小时前
【LPieces-UI】02-Icon组件的设计与实现
前端·vue.js
卤蛋fg62 小时前
vxe组件 vxe-table 权限控制:通过 permission-code 实现按钮级显隐
vue.js
豆苗学前端2 小时前
【前端内功】同为数据驱动,为什么只有 React 的"心智负担"这么重?(附实战优化指南)
前端·vue.js·面试
铁皮饭盒2 小时前
震惊, Bun突发新版, 重写核心, 换掉了底层Zig
前端·javascript·后端