交互内容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();
}