效果图如下
该效果代码组件如下
<script lang="ts" setup>
/**
* 接收组件参数
* @params modelValue v-model双向绑定输入值
* @params kewords 匹配关键字数组,例:['关键字1', '关键字2']
* @params color 匹配关键字高亮颜色 例:#333333
*/
import { nextTick, ref, watch, onMounted } from 'vue'
const inputHtml = ref('')
const inputDom = ref()
let isLock = false
const props = defineProps({
/*modelValue: {
type: String,
default: ''
},*/
keywords: {
type: Array,
default: []
},
color: {
type: String,
default: '#F56C6C'
}
})
const emit = defineEmits(['update:modelValue','updatedata'])
const onCompositionStart = () => {
// 中文输入锁定
isLock = true
}
const onCompositionEnd = (e: Event) => {
isLock = false
onInput(e)
}
// 还原光标位置
const setCaretPos = (el: HTMLElement, pos: number) => {
let selection = getSelection()
let range = createRange(inputDom.value, { pos })
if (range) {
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
}
}
const createRange = (node: Node, obj: { pos: number }, range?: Range): Range => {
if (!range) {
range = document.createRange()
range.selectNode(node)
range.setStart(node, 0)
}
if (obj.pos === 0) {
range.setEnd(node, obj.pos)
} else if (node && obj.pos > 0) {
if (node.nodeType === Node.TEXT_NODE) {
let text = node.textContent || ''
if (text.length < obj.pos) {
obj.pos -= text.length
} else {
range.setEnd(node, obj.pos)
obj.pos = 0
}
} else {
if (node.nodeName === 'BR') {
obj.pos -= 1
if (obj.pos === 0) {
range.setEnd(node.nextSibling || node, 0)
}
} else {
for (var lp = 0; lp < node.childNodes.length; lp++) {
range = createRange(node.childNodes[lp], obj, range)
if (obj.pos === 0) {
break
}
}
}
}
}
return range
}
// 正则转义
const escapeRegExp = (text: string) => {
return text.replace(/[\-\/\\\^\$\*\+\?\.\(\)\|\[\]\{\}]/g, '\\$&');
}
function isNumericString(str: string): boolean {
return !isNaN(str as any) || /^\d+(\.\d+)?$/.test(str);
}
// html转义
const htmlEncode = (str: string) => {
var s = "";
if (str.length == 0) return "";
s = str.replace(/&/g, "&");
s = s.replace(/</g, "<");
s = s.replace(/>/g, ">");
s = s.replace(/ /g, " ");
s = s.replace(/\'/g, "'");
s = s.replace(/\"/g, """);
s = s.replace(/\n/g, "<br/>");
return s;
}
//关键字包裹
const htmlTextReplace = (text: string | null, keywords: any[]) => {
if (!text) return ''
const regexp = new RegExp(
keywords
.map((keyword) => escapeRegExp(String(keyword).trim()))
.join('|'),
'gi'
)
let textArr: string[] = text.replace(/\t/g, '').replace(regexp, '\t$&\t').split(/\t/)
return textArr.map((str, i) => {
return i % 2 === 0 ? str : (isNumericString(str)?`<input style=" min-width:20px;margin:0px 2px; background-color: rgba(110, 75, 250, .09);padding:2px;border-radius:2px;color: ${props.color}" placeholder="${str}" type="number" value="${str}"></input>`:
`<span class=" input-custom-placeholder input-custom-item" style=" display:inline-block;min-width:50px;border-radius:2px;" deplaceholder="${str.replaceAll('{','').replaceAll('}','')}" placeholder="${str.replaceAll('{','').replaceAll('}','')}"></span>`)
}).join('')
/*return textArr.map((str, i) => {
return i % 2 === 0 ? str : (isNumericString(str)?`<input style=" min-width:50px;margin:0px 2px; background-color: rgba(110, 75, 250, .09);padding:2px;border-radius:2px;color: ${props.color}" placeholder="${str}" type="number" value="${str}"></input>`:
`<input class="input-custom-item input-custom-placeholder" style=" height:22px;display:inline-block;min-width:50px;margin:0px 2px; background-color: rgba(110, 75, 250, .09);padding:2px;border-radius:2px;color: ${props.color}" placeholder="${str.replaceAll('{','').replaceAll('}','')}"></input>`)
}).join('')*/
}
const formatTextReplace = async (text: string,replaceWords) => {
console.info(replaceWords)
let encodeText = htmlEncode(text)
inputHtml.value = htmlTextReplace(encodeText, replaceWords)
await nextTick()
setCaretPos(inputDom.value, 0)
}
const htmlHasInput = ()=>{
const div = document.getElementById('myDivId');
if (!div) return false;
// 检查是否有input元素
const hasInput = inputHtml.value.includes('input-custom-placeholder') || div.querySelector('input') !== null;
return hasInput;
}
function updatePlaceholders() {
const spans = document.querySelectorAll('#myDivId span');
spans.forEach(span => {
if (span.textContent.trim() !== '') {
span.removeAttribute('placeholder'); // 移除 placeholder 属性
// 或者如果需要隐藏 placeholder 文本,可以使用以下代码:
// span.style.setProperty('placeholder', 'none'); // 注意:这种方法在某些浏览器中可能不起作用
} else {
span.setAttribute('placeholder', span.getAttribute('deplaceholder') || ''); // 设置 placeholder 属性
}
});
}
// html反转义
const htmlDecode = (str: string) => {
var s = "";
if (str.length == 0) return "";
s = str.replace(/&/g, "&");
s = s.replace(/</g, "<");
s = s.replace(/>/g, ">");
s = s.replace(/ /g, " ");
s = s.replace(/'/g, "\'");
s = s.replace(/"/g, "\"");
s = s.replace(/<br\/>/g, "\n");
return s;
}
const onInput = (e: Event) => {
console.info('输入事件触发')
if (!isLock) {
updatePlaceholders()
let text = document.getElementById('myDivId').innerText
emit('updatedata', htmlDecode(text || ''))
}
}
const resetHtml = () => {
inputHtml.value = ''
document.getElementById('myDivId').innerHTML = ''
nextTick(() => {
setCaretPos(inputDom.value, 0)
})
}
onMounted(() => {
document.getElementById('myDivId').addEventListener('paste', function(e) {
e.preventDefault()
var text = (e.clipboardData || window.clipboardData).getData('text/plain')
document.execCommand('insertText',false,text)
});
})
defineExpose({
formatTextReplace,resetHtml,htmlHasInput
})
</script>
<script lang="ts">
export default {
name: 'HighlightInput'
}
</script>
<template>
<div @input="onInput" @compositionstart="onCompositionStart" @compositionend="onCompositionEnd" ref="inputDom" id="myDivId" v-html="inputHtml" contenteditable="true" class="highlight-input ">
</div>
</template>
<style scoped lang='scss'>
.highlight-input {
min-height: 200px;
width: 100%;
max-height:300px ;
outline: #c0c4cc 1px solid;
border-radius: 2px;
padding: 2px 11px 1px 11px;
overflow: auto;
// white-space: nowrap;
// 隐藏滚动条
-ms-overflow-style: none;
overflow: -moz-scrollbars-none;
padding-bottom: 40px;
background-color: #fff;
&::-webkit-scrollbar {
width: 0 !important;
}
&:focus-visible {
outline-color: #409eff;
}
&:empty:before {
content: attr(placeholder);
color: #999;
}
&:focus:before {
content: '';
}
}
</style>
<style lang='scss'>
.input-custom-item {
/*background-color: rgba(110,75,250,.09);*/
border-radius: 9px;
color: #6e4bfa !important;
display: inline-block;
font-weight: 500;
line-height: 28px;
max-width: calc(100% - 27px);
padding: 0 6px;
min-height: 28px;
min-width: 100px;
background-color: rgba(110, 75, 250, .09) !important;
}
.input-custom-placeholder::after {
color: rgba(110,75,250,.4) !important;
content: attr(placeholder)
}
</style>
参考原始代码效果如下
<script lang="ts" setup>
/**
* 接收组件参数
* @params modelValue v-model双向绑定输入值
* @params kewords 匹配关键字数组,例:['关键字1', '关键字2']
* @params color 匹配关键字高亮颜色 例:#333333
*/
import { nextTick, ref, watch, onMounted } from 'vue'
const inputHtml = ref('')
const inputDom = ref()
let isLock = false
const props = defineProps({
modelValue: {
type: String,
default: ''
},
keywords: {
type: Array,
default: []
},
color: {
type: String,
default: '#F56C6C'
}
})
const emit = defineEmits(['update:modelValue'])
watch(() => props.modelValue, (val) => {
formatText(val)
})
// 正则转义
const escapeRegExp = (text: string) => {
return text.replace(/[\-\/\\\^\$\*\+\?\.\(\)\|\[\]\{\}]/g, '\\$&');
}
// html转义
const htmlEncode = (str: string) => {
var s = "";
if (str.length == 0) return "";
s = str.replace(/&/g, "&");
s = s.replace(/</g, "<");
s = s.replace(/>/g, ">");
s = s.replace(/ /g, " ");
s = s.replace(/\'/g, "'");
s = s.replace(/\"/g, """);
s = s.replace(/\n/g, "<br/>");
return s;
}
// html反转义
const htmlDecode = (str: string) => {
var s = "";
if (str.length == 0) return "";
s = str.replace(/&/g, "&");
s = s.replace(/</g, "<");
s = s.replace(/>/g, ">");
s = s.replace(/ /g, " ");
s = s.replace(/'/g, "\'");
s = s.replace(/"/g, "\"");
s = s.replace(/<br\/>/g, "\n");
return s;
}
//关键字包裹
const htmlText = (text: string | null, keywords: any[]) => {
if (!text) return ''
const regexp = new RegExp(
keywords
.map((keyword) => escapeRegExp(String(keyword).trim()))
.join('|'),
'gi'
)
let textArr: string[] = text.replace(/\t/g, '').replace(regexp, '\t$&\t').split(/\t/)
return textArr.map((str, i) => {
return i % 2 === 0 ? str : `<span style="color: ${props.color}">${str}</span>`
}).join('')
}
// 防抖
const debounce = (fn: Function, delay: number) => {
let timer: null | number = null
return (...arg: any[]) => {
if (timer) {
clearTimeout(timer)
timer = null
}
timer = setTimeout(() => {
fn.apply(this, arg)
}, delay)
}
}
const formatText = async (text: string) => {
let pos = getCaretPos(inputDom.value) + caretOffset
let encodeText = htmlEncode(text)
inputHtml.value = htmlText(encodeText, props.keywords)
await nextTick()
setCaretPos(inputDom.value, pos)
caretOffset = 0
}
// 获取光标偏移,包含换行符
const getCaretPos = (el: HTMLInputElement) => {
el.focus()
let range = document.getSelection()?.getRangeAt(0) as Range
let rangeClone = range?.cloneRange()
rangeClone?.selectNodeContents(el)
rangeClone?.setEnd(range?.endContainer, range?.endOffset)
// return rangeClone?.toString().length
return countDocumentFragment(rangeClone.cloneContents())
}
// 计算range所有子节点字符数
function countDocumentFragment(fragment: DocumentFragment) {
var text = '';
var childNodes = fragment.childNodes;
for (var i = 0; i < childNodes.length; i++) {
var node = childNodes[i];
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
} else if (node.nodeType === Node.ELEMENT_NODE) {
text += countCharacterElement(node);
}
}
return text.length;
}
// 元素节点
function countCharacterElement(element: Node) {
var text = '';
if (element.nodeName === 'BR') {
return text + ' '
}
var childNodes = element.childNodes;
for (var i = 0; i < childNodes.length; i++) {
var node = childNodes[i];
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
} else if (node.nodeType === Node.ELEMENT_NODE) {
if (node.nodeName === 'BR') {
text += ' '
} else {
text += countCharacterElement(node);
}
}
}
return text;
}
// 还原光标位置
const setCaretPos = (el: HTMLElement, pos: number) => {
let selection = getSelection()
let range = createRange(inputDom.value, { pos })
if (range) {
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
}
}
const createRange = (node: Node, obj: { pos: number }, range?: Range): Range => {
if (!range) {
range = document.createRange()
range.selectNode(node)
range.setStart(node, 0)
}
if (obj.pos === 0) {
range.setEnd(node, obj.pos)
} else if (node && obj.pos > 0) {
if (node.nodeType === Node.TEXT_NODE) {
let text = node.textContent || ''
if (text.length < obj.pos) {
obj.pos -= text.length
} else {
range.setEnd(node, obj.pos)
obj.pos = 0
}
} else {
if (node.nodeName === 'BR') {
obj.pos -= 1
if (obj.pos === 0) {
range.setEnd(node.nextSibling || node, 0)
}
} else {
for (var lp = 0; lp < node.childNodes.length; lp++) {
range = createRange(node.childNodes[lp], obj, range)
if (obj.pos === 0) {
break
}
}
}
}
}
return range
}
const deFormatText = debounce(formatText, 300)
const onCompositionStart = () => {
// 中文输入锁定
isLock = true
}
const onCompositionEnd = (e: Event) => {
isLock = false
onInput(e)
}
const onInput = (e: Event) => {
if (!isLock) {
let text = (e.target as HTMLElement).innerText
emit('update:modelValue', htmlDecode(text || ''))
// deFormatText(text)
}
}
const onEnterDown = (event: KeyboardEvent) => {
// enter 和 shift+enter 表现不一致,需要单独处理换行逻辑
if (!event.shiftKey) {
event.preventDefault()
var selection = window.getSelection() as Selection
var range = selection.getRangeAt(0)
let endContainer = range.endContainer
let parentNode = endContainer.parentNode || inputDom.value
let pos = getCaretPos(inputDom.value)
if (pos === inputDom.value.innerText.length) {
var br1 = document.createElement("br")
var br2 = document.createElement("br")
if (endContainer.nodeName === 'BR') {
parentNode.insertBefore(br1, endContainer.nextSibling)
parentNode.insertBefore(br2, endContainer.nextSibling)
range.setStartBefore(br2)
range.setEndBefore(br2)
} else {
range.insertNode(br1)
range.insertNode(br2)
range.setStartAfter(br2)
range.setEndAfter(br2)
}
} else {
var br1 = document.createElement("br");
if (endContainer.nodeName === 'BR') {
parentNode.insertBefore(br1, endContainer.nextSibling)
range.setStartBefore(br1)
range.setEndBefore(br1)
} else {
range.insertNode(br1)
range.setStartAfter(br1)
range.setEndAfter(br1)
}
}
}
}
let caretOffset = 0
onMounted(() => {
inputDom.value.addEventListener("paste", (e: ClipboardEvent) => {
e.preventDefault()
if(!e.clipboardData) return
const text = e.clipboardData.getData('text')
const cleanedText = text.replace(/\r/g, '')
let pos = getCaretPos(inputDom.value)
let innerText = inputDom.value.innerText
let inputText = innerText.substr(0, pos) + cleanedText + innerText.substr(pos)
caretOffset = cleanedText.length
emit('update:modelValue', htmlDecode( inputText|| ''))
});
})
</script>
<script lang="ts">
export default {
name: 'HighlightInput'
}
</script>
<template>
<div ref="inputDom" v-html="inputHtml" class="highlight-input" @input="onInput" @compositionstart="onCompositionStart"
@compositionend="onCompositionEnd" contenteditable="true" @keydown.enter="onEnterDown"></div>
</template>
<style scoped lang='scss'>
.highlight-input {
height: 30px;
width: 100%;
outline: #c0c4cc 1px solid;
border-radius: 2px;
padding: 2px 11px 1px 11px;
overflow: auto;
// white-space: nowrap;
// 隐藏滚动条
-ms-overflow-style: none;
overflow: -moz-scrollbars-none;
&::-webkit-scrollbar {
width: 0 !important;
}
&:focus-visible {
outline-color: #409eff;
}
&:empty:before {
content: attr(placeholder);
color: #999;
}
&:focus:before {
content: '';
}
}
</style>
使用方式
<script lang="ts" setup>
import {ref} from 'vue'
// import highlightInput from '@/components/HighlightInput.vue'
const myText = ref('')
const keywords = ref(['鼠鼠', '润'])
const color = ref('#67C23A')
</script>
<template>
<div class="demo">
<header>
<h1>看图说话</h1>
<img src="@/assets/shushu.png" alt="">
</header>
<highlightInput v-model="myText" :keywords="keywords" :color="color" placeholder="关键字(鼠、润)" class="high-light"></highlightInput>
<p>{{ myText }}</p>
</div>
</template>
<style scoped lang='scss'>
.demo {
width: 550px;
h1 {
text-align: center;
}
header {
margin-bottom: 10px;
}
p {
margin-top: 10px;
}
.high-light {
height: 200px;
}
}
</style>