You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fu-hsi-web/src/views/promptManagement/PromptConfig/add/PromptInput.vue

832 lines
25 KiB
Vue

7 months ago
<template>
<div class="custom-at-box">
<div class="custom-textarea-box">
<div
ref="customInput"
:class="[
'custom-textarea custom-scroll',
{ 'show-word-limit': showWordLimit && maxlength },
{ 'custom-textarea-disabled': disabled },
]"
:contenteditable="!disabled"
:placeholder="placeholder"
@input="onInput($event)"
@keydown="onKeyDownInput($event)"
@paste="onPaste($event)"
@copy="onCopy($event)"
@click="showList(false)"
/>
<div v-if="showWordLimit && maxlength" class="custom-at-limit">
{{ inputValueLen }}/{{ maxlength }}
</div>
</div>
<div :key="`customInput${taskPanelIsInFullScreen}`">
<el-popover
ref="popoverRef"
v-model="showPopover"
trigger="click"
class="custom-select-box"
:append-to-body="taskPanelPopoverAppendToBody"
:style="{ top: popoverOffset + 'px' }"
@hide="hidePoppver"
>
<div
ref="customSelectContent"
class="custom-select-content custom-scroll"
>
<div
v-for="(item, index) in dataList"
:key="index"
:class="[
'custom-select-item',
{ hoverItem: selectedIndex === index },
]"
@click="handleClickOperatorItem(item)"
>
<div class="custom-select-item-content">
{{ item.name }}
</div>
</div>
</div>
</el-popover>
</div>
</div>
</template>
<script>
import Vue from 'vue'
export default {
props: {
// 输入框placeholder
placeholder: {
type: String,
default: '请输入...'
},
// 是否显示输入字数统计
showWordLimit: {
type: Boolean,
default: false
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 最大输入长度
maxlength: {
type: [Number, String],
default: '3000'
},
setRefresh: {
type: Object,
default: () => {}
},
// 输入框输入的内容
value: {
type: String,
default: ''
}
},
data() {
return {
// 已输入内容的长度
inputValueLen: 0,
top: '',
left: '',
message: '',
startOffset: 0,
// @搜索人dom
searSpan: null,
// 筛选人数据
dataList: [
{
name: 'xxxx'
},
{
name: 'dddd'
}
],
// @插入位置
selectionIndex: 0,
// 当前编辑的dom
dom: null,
// 当前编辑dom的index
domIndex: 0,
// 当前编辑dom的childNodes的index
childDomIndex: 0,
// 编辑前dom内容
beforeDomVal: '',
// 筛选人选择框
showPopover: false,
// 筛选人选择框偏移量
popoverOffset: 0,
listInput: false,
listInputValue: '',
// 防抖
timer: null,
// 保存弹窗加载状态
addDataLoad: false,
// 鼠标选择人的索引
selectedIndex: 0
}
},
computed: {
// 计算属性,用于同步父组件的数据
model: {
get() {
return this.value
},
set(newValue) {
this.$emit('input', newValue)
if (this.$refs.customInput) {
this.$emit('inputText', this.$refs.customInput.textContent)
}
const nodeList = this.$refs.customInput.childNodes
const list = []
nodeList.forEach((e) => {
if (e.childNodes) {
e.childNodes.forEach(i => {
if (i.className === 'active-text') {
list.push({
jobNumber: i.getAttribute('data-id'),
name: i.textContent.replace(/{/g, '').replace(/\s/g, '')
})
}
})
}
if (e.className === 'active-text') {
list.push({
jobNumber: e.getAttribute('data-id'),
name: e.textContent.replace(/{/g, '').replace(/\s/g, '')
})
}
})
this.$emit('changeChosen', list)
}
},
taskPanelIsInFullScreen() {
return this.$store.getters.taskPanelIsInFullScreen
},
taskPanelPopoverAppendToBody() {
return this.$store.getters.taskPanelPopoverAppendToBody
}
},
mounted() {
this.setNativeInputValue()
},
methods: {
// 设置输入框的值
setNativeInputValue() {
if (this.$refs.customInput) {
if (this.value === this.$refs.customInput.innerHTML) return
this.$refs.customInput.innerHTML = this.value
this.inputValueLen = this.$refs.customInput.innerText.length
}
},
// 筛选人弹窗数据选择
handleClickOperatorItem(item) {
this.addData(JSON.parse(JSON.stringify(item)))
this.$refs.customSelectContent.scrollTop = 0
this.selectedIndex = 0
this.showPopover = false
this.listInput = false
this.listInputValue = ''
},
// 艾特人弹窗关闭
hidePoppver() {
this.$refs.customSelectContent.scrollTop = 0
this.selectedIndex = 0
this.showPopover = false
this.listInput = false
this.listInputValue = ''
},
// 创建艾特需要插入的元素
createAtDom(item) {
// 先判断剩余输入长度是否能够完整插入元素
const dom = document.createElement('span')
dom.classList.add('active-text')
// 这里的contenteditable属性设置为false删除时可以整块删除
dom.setAttribute('contenteditable', 'false')
// 将id存储在dom元素的标签上便于后续数据处理
dom.setAttribute('data-id', item.name)
dom.innerHTML = `{${item.name}}&nbsp;`
return dom
},
// 插入元素
addData(item) {
const spanElement = this.createAtDom(item)
const maxlength = Number(this.maxlength) || 3000
// 因为插入后需要删除之前输入的@所以判断长度时需要减去这个1
if (maxlength - this.inputValueLen < spanElement.innerText.length - 1) {
this.$message('剩余字数不足')
return
}
this.$refs.customInput.focus()
// 获取当前光标位置的范围
const selection = window.getSelection()
const range = selection.getRangeAt(0)
// 找到要插入的节点
const nodes = Array.from(this.$refs.customInput.childNodes)
let insertNode = ''
// 是否是子元素
let domIsCustomInputChild = true
if (nodes[this.domIndex].nodeType === Node.TEXT_NODE) {
insertNode = nodes[this.domIndex]
} else {
const childNodeList = nodes[this.domIndex].childNodes
insertNode = childNodeList[this.childDomIndex]
domIsCustomInputChild = false
}
// 如果前一个节点是空的文本节点,@用户无法删除
// 添加判断条件:如果前一个节点是空的文本节点,则插入一个空的<span>节点
const html = insertNode.textContent
// 左边的节点
const textLeft = document.createTextNode(
html.substring(0, this.selectionIndex - 1) + ''
)
const emptySpan = document.createElement('span')
// 如果找到了要插入的节点,则在其前面插入新节点
if (insertNode) {
if (!textLeft.textContent) {
if (domIsCustomInputChild) {
this.$refs.customInput.insertBefore(emptySpan, insertNode)
} else {
nodes[this.domIndex].insertBefore(emptySpan, insertNode)
}
}
insertNode.parentNode.insertBefore(spanElement, insertNode.nextSibling)
// 删除多余的@以及搜索条件
const textContent = insertNode.textContent.slice(
0,
-(1 + this.listInputValue.length)
)
if (!textContent && insertNode.nodeName === '#text') {
insertNode.remove()
} else {
insertNode.textContent = textContent
}
} else {
// 如果未找到要插入的节点,则将新节点直接追加到末尾
this.$refs.customInput.appendChild(spanElement)
}
// 将光标移动到 span 元素之后
const nextNode = spanElement.nextSibling
range.setStart(
nextNode || spanElement.parentNode,
nextNode ? 0 : spanElement.parentNode.childNodes.length
)
range.setEnd(
nextNode || spanElement.parentNode,
nextNode ? 0 : spanElement.parentNode.childNodes.length
)
selection.removeAllRanges()
selection.addRange(range)
this.model = this.$refs.customInput.innerHTML
this.inputValueLen = this.$refs.customInput.innerText.length
this.showList(false)
},
// 检查是否发生了全选操作
isSelectAll() {
const selection = window.getSelection()
return selection.toString() === this.$refs.customInput.innerText
},
// 获取输入框是否选中文字
isSelect() {
try {
const selection = window.getSelection()
return selection.toString().length
} catch (error) {
return 0
}
},
// 输入事件
onKeyDownInput(event) {
// 获取当前输入框的长度
const currentLength = this.$refs.customInput.innerText.length
// 获取最大输入长度限制
const maxLength = Number(this.maxlength) || 3000
// 如果按下的键是非控制键并且当前长度已经达到了最大长度限制
if (currentLength >= maxLength) {
// 获取按键的 keyCode
var keyCode = event.keyCode || event.which
// 检查是否按下了 Ctrl 键
var ctrlKey = event.ctrlKey || event.metaKey // metaKey 用于 macOS 上的 Command 键
// 允许的按键Backspace8、Delete46、方向键和
var allowedKeys = [8, 46, 37, 38, 39, 40]
// 允许的按键 Ctrl+A、Ctrl+C、Ctrl+V
const allowedCtrlKey = [65, 67, 86]
// 检查按键是否在允许列表中并且没有执行选中操作
if (!allowedKeys.includes(keyCode) && !this.isSelect()) {
if ((allowedCtrlKey.includes(keyCode) && ctrlKey)) {
return
}
// 阻止默认行为
event.preventDefault()
return false
}
}
if (this.showPopover) {
const listElement = this.$refs.customSelectContent
const itemHeight = listElement.children[0].clientHeight
if (event.key === 'ArrowDown') {
// 防止光标移动
event.preventDefault()
// 移动选中索引
if (this.selectedIndex === this.dataList.length - 1) {
this.selectedIndex = 0 // 跳转到第一项
listElement.scrollTop = 0 // 滚动到列表顶部
} else {
this.selectedIndex++
const itemBottom = (this.selectedIndex + 1) * itemHeight
const scrollBottom = listElement.scrollTop + listElement.clientHeight
if (itemBottom > scrollBottom) {
listElement.scrollTop += itemHeight
}
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
if (this.selectedIndex === 0) {
this.selectedIndex = this.dataList.length - 1 // 跳转到最后一项
listElement.scrollTop = listElement.scrollHeight // 滚动到列表底部
} else {
this.selectedIndex--
const itemTop = this.selectedIndex * itemHeight
if (itemTop < listElement.scrollTop) {
listElement.scrollTop -= itemHeight
}
}
} else if (event.key === 'Enter') {
event.preventDefault()
this.handleClickOperatorItem(
this.dataList[this.selectedIndex]
)
}
} else if (event.key === 'Backspace' && this.isSelectAll()) {
// 如果执行了全选操作并删除,清空输入框内容
this.$refs.customInput.innerText = ''
this.model = this.$refs.customInput.innerHTML
this.inputValueLen = 0
}
},
// 监听输入事件
onInput(e) {
this.inputValueLen = this.$refs.customInput.innerText.length
if (
['<div><br></div>', '<br>', '<span></span><br>'].includes(
this.$refs.customInput.innerHTML
)
) {
this.$refs.customInput.innerHTML = ''
this.inputValueLen = 0
} else if (e.data === '{') {
// 保存焦点位置
this.saveIndex()
this.showList()
this.listInput = true
} else if (this.showPopover) {
const { diffChars } = Vue.internal
const diffResult = diffChars(
this.beforeDomVal,
this.dom.textContent
)
let result = ''
// 遍历差异信息数组
for (let i = 0; i < diffResult.length; i++) {
const change = diffResult[i]
// 如果当前差异是添加或修改类型,则将其添加到结果字符串中
if (change.added) {
result += change.value
} else if (change.removed && change.value === '{') {
this.showList(false)
this.listInputValue = ''
}
}
if (this.timer) {
clearTimeout(this.timer)
}
this.listInputValue = result
this.timer = setTimeout(() => {
this.remoteMethod()
}, 300)
}
this.model = this.$refs.customInput.innerHTML
},
onPaste(event) {
event.preventDefault()
// 获取剪贴板中的 HTML 和文本内容
const html = (event.clipboardData || window.clipboardData).getData(
'text/html'
)
const text = (event.clipboardData || window.clipboardData).getData(
'text/plain'
)
// 设置最大输入限制
const maxLength = Number(this.maxlength) || 3000
// 此时加个条件 看鼠标选中的文本长度,剩余可输入长度加上选中文本长度
const selection1 = window.getSelection()
const range1 = selection1.getRangeAt(0)
const clonedSelection = range1.cloneContents()
let selectTextLen = 0
if (clonedSelection.textContent && clonedSelection.textContent.length) {
selectTextLen = clonedSelection.textContent.length
}
// 剩余可输入长度
const remainingLength = maxLength - this.inputValueLen + selectTextLen
// 过滤掉不可见字符
const cleanText = text.replace(/\s/g, '')
// 创建一个临时 div 用于处理粘贴的 HTML 内容
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
// 过滤掉不需要的内容,例如注释和换行符
const fragment = document.createDocumentFragment()
let totalLength = 0
if (cleanText) {
if (remainingLength >= cleanText.length) {
fragment.appendChild(document.createTextNode(cleanText))
} else {
const truncatedText = cleanText.substr(0, remainingLength)
fragment.appendChild(document.createTextNode(truncatedText))
}
} else {
Array.from(tempDiv.childNodes).forEach((node) => {
const regex = /<span class="active-text" contenteditable="false" data-id="(\d+)">{([^<]+)<\/span>/g
// 过滤注释和空白节点
if (
node.nodeType !== 8 &&
!(node.nodeType === 3 && !/\S/.test(node.textContent))
) {
const childText = node.textContent || ''
const childLength = childText.length
const childHtml = node.outerHTML || node.innerHTML
// 如果剩余空间足够,插入节点
if ((regex.exec(childHtml) !== null) && totalLength + childLength <= remainingLength) {
fragment.appendChild(node.cloneNode(true))
totalLength += childLength
} else if (remainingLength - totalLength > 0) {
// 如果还有剩余长度,不插入节点,插入文本内容
const lastNodeLength = remainingLength - totalLength
const truncatedText = childText.substr(0, lastNodeLength)
fragment.appendChild(document.createTextNode(truncatedText))
totalLength += truncatedText.length
} else {
// 如果添加当前节点的内容会超出剩余可插入长度,则结束循环
return
}
}
})
}
// 插入处理后的内容到光标位置
const selection = window.getSelection()
const range = selection.getRangeAt(0)
range.deleteContents()
range.insertNode(fragment)
// 更新输入框内容和长度
this.model = this.$refs.customInput.innerHTML
this.inputValueLen = this.$refs.customInput.innerText.length
// 设置光标位置为插入内容的后面一位
const newRange = document.createRange()
newRange.setStart(range.endContainer, range.endOffset)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
},
// 修改默认复制事件
onCopy(e) {
e.preventDefault()
const selection = window.getSelection()
const range = selection.getRangeAt(0)
const clonedSelection = range.cloneContents()
// 检查复制的内容是否包含符合条件的元素
const hasActiveText =
clonedSelection.querySelector(
'.active-text[contenteditable="false"][data-id]'
) !== null
const clipboardData = e.clipboardData || window.clipboardData
if (hasActiveText) {
const div = document.createElement('div')
div.appendChild(clonedSelection)
const selectedHtml = div.innerHTML
clipboardData.setData('text/html', selectedHtml)
} else {
clipboardData.setData('text/plain', clonedSelection.textContent || '')
}
},
// 保存焦点位置
async saveIndex() {
const selection = getSelection()
this.selectionIndex = selection.anchorOffset
const nodeList = this.$refs.customInput.childNodes
const range = selection.getRangeAt(0)
// 保存当前编辑的dom节点
for (const [index, value] of nodeList.entries()) {
// 这里第二个参数要配置成true没配置有其他的一些小bug
// (range.startContainer.contains(value) && range.endContainer.contains(value)) 是为了处理兼容性问题
if (
selection.containsNode(value, true) ||
(range.startContainer.contains(value) &&
range.endContainer.contains(value))
) {
if (value.nodeType === Node.TEXT_NODE) {
this.dom = value
this.beforeDomVal = value.textContent
this.domIndex = index
const selection = window.getSelection()
const range = selection.getRangeAt(0)
this.startOffset = range.startOffset - 1
} else {
const childNodeList = value.childNodes
for (const [childIndex, childValue] of childNodeList.entries()) {
if (selection.containsNode(childValue, true)) {
this.dom = value
this.beforeDomVal = value.textContent
this.domIndex = index
this.childDomIndex = childIndex
const selection = window.getSelection()
const range = selection.getRangeAt(0)
this.startOffset = range.startOffset - 1
}
}
}
}
}
},
// 筛选人弹窗
showList(bool = true) {
this.showPopover = bool
if (bool) {
const offset =
this.getCursorDistanceFromDivBottom(this.$refs.customInput) || -1
if (offset < 0) {
this.popoverOffset = 0
} else {
this.popoverOffset = -(offset - 1)
}
}
if (!bool) {
this.listInputValue = ''
this.remoteMethod()
}
},
// 获取光标位置
getCursorDistanceFromDivBottom(editableDiv) {
// 获取选区
const selection = window.getSelection()
// 获取选区的范围
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null
if (range) {
// 创建一个临时元素来标记范围的结束位置
const markerElement = document.createElement('span')
// 插入临时标记元素
range.insertNode(markerElement)
markerElement.appendChild(document.createTextNode('\u200B')) // 零宽空格
// 获取标记元素的位置信息
const markerOffsetTop = markerElement.offsetTop
const markerHeight = markerElement.offsetHeight
// 计算光标距离div底部的距离
const cursorDistanceFromBottom =
editableDiv.offsetHeight - (markerOffsetTop + markerHeight)
// 滚动条距顶部的高度
const scrollTop = editableDiv.scrollTop || 0
// 移除临时标记元素
markerElement.parentNode.removeChild(markerElement)
// 返回光标距离底部的距离
return cursorDistanceFromBottom + scrollTop
}
// 如果没有选区,则返回-1或者其他错误值
return -1
},
// 搜索筛选人
async remoteMethod() {
},
handleNameShift(item) {
const name = item.name || ''
if (!name) return '--'
if (name.length > 1) {
return name.slice(0, 1)
} else {
return name
}
},
// 按钮div点击 聚焦textarea
handleBtnBoxClick() {
this.$refs.customInput.focus()
},
getInnerText() {
const customInput = this.$refs.customInput
if (!customInput) return
return customInput.innerText
},
getJobId() {
const nodeList = this.$refs.customInput.childNodes
const list = []
nodeList.forEach((e) => {
if (e.className === 'active-text') {
list.push(e.getAttribute('data-id'))
}
})
return list
},
clearInput() {
this.$refs.customInput.innerText = ''
this.$refs.customInput.innerHTML = ''
this.inputValueLen = 0
this.$emit('input', '')
this.$emit('inputText', '')
this.$emit('changeChosen', [])
}
}
}
</script>
<style lang="scss" scoped>
.custom-textarea-btn {
position: absolute;
bottom: 1px;
right: 4px;
left: 4px;
text-align: right;
// background: #fff;
padding-bottom: 3px;
.el-button {
font-size: 12px;
padding: 4px 10px;
}
}
.custom-textarea-box {
position: relative;
}
.custom-at-limit {
position: absolute;
right: 12px;
bottom: 4px;
font-size: 12px;
color: #999;
line-height: 12px;
}
::v-deep.custom-textarea {
height: calc(100vh - 400px);
background-color: #ffffff;
padding: 5px 15px;
color: #606266;
overflow-y: auto;
line-height: 20px;
font-size: 14px;
transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
position: relative;
word-break: break-all;
.el-textarea__inner {
border: none;
resize: none;
}
&.show-word-limit {
padding-bottom: 16px;
}
&.custom-textarea-disabled {
cursor: not-allowed;
background-color: #f5f7fa;
border-color: #e4e7ed;
color: #c0c4cc;
}
&:focus {
border-color: #f98600 !important;
}
&:empty::before {
content: attr(placeholder);
font-size: 14px;
color: #c0c4cc;
}
.active-text {
color: #909399;
// padding: 2px 6px;
// background: #f4f4f5;
margin-right: 4px;
// border-radius: 4px;
// font-size: 12px;
}
// &:focus::before {
// content: "";
// }
}
::v-deep.custom-select-box {
position: relative;
.el-popover {
padding: 0;
top: 0;
box-shadow: 0 4px 8px 0 rgba(89, 88, 88, 0.8);
}
.custom-select-content {
width: 259px;
padding: 8px;
max-height: 260px;
overflow-y: auto;
}
.custom-select-item {
// font-size: 14px;
// padding: 0 20px;
// position: relative;
// height: 34px;
// line-height: 34px;
// box-sizing: border-box;
display: flex;
padding: 8px 12px;
border-bottom: 1px solid #ebebeb;
align-items: center;
color: #606266;
cursor: pointer;
&:last-child {
border-bottom: none;
}
.avatar-box {
flex-shrink: 0;
.custom-select-item-avatar {
width: 24px;
height: 24px;
background-color: #ffb803;
border-radius: 50%;
text-align: center;
line-height: 24px;
color: #ffffff;
}
}
.custom-select-item-content {
flex: 1;
padding-left: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
background-color: #f5f7fa;
}
&.hoverItem {
background-color: #dbdbdb;
}
}
.custom-select-empty {
padding: 10px 0;
text-align: center;
color: #999;
font-size: 14px;
&.load {
display: flex;
align-items: center;
justify-content: center;
}
}
.custom-scroll {
overflow: auto;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-thumb {
border-radius: 8px;
background-color: #b4b9bf;
}
}
}
</style>