vue3输入框效果,仿照百度AI的输入。有span输入占位,placeholder,可删除修改复制粘贴文字,使用contenteditable

效果图如下

该效果代码组件如下

<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, "&amp;");
  s = s.replace(/</g, "&lt;");
  s = s.replace(/>/g, "&gt;");
  s = s.replace(/ /g, "&nbsp;");
  s = s.replace(/\'/g, "&#39;");
  s = s.replace(/\"/g, "&quot;");
  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(/&amp;/g, "&");
  s = s.replace(/&lt;/g, "<");
  s = s.replace(/&gt;/g, ">");
  s = s.replace(/&nbsp;/g, " ");
  s = s.replace(/&#39;/g, "\'");
  s = s.replace(/&quot;/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, "&amp;");
  s = s.replace(/</g, "&lt;");
  s = s.replace(/>/g, "&gt;");
  s = s.replace(/ /g, "&nbsp;");
  s = s.replace(/\'/g, "&#39;");
  s = s.replace(/\"/g, "&quot;");
  s = s.replace(/\n/g, "<br/>");
  return s;
}
// html反转义
const htmlDecode = (str: string) => {
  var s = "";
  if (str.length == 0) return "";
  s = str.replace(/&amp;/g, "&");
  s = s.replace(/&lt;/g, "<");
  s = s.replace(/&gt;/g, ">");
  s = s.replace(/&nbsp;/g, " ");
  s = s.replace(/&#39;/g, "\'");
  s = s.replace(/&quot;/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>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值