Files
aida_front/src/component/home/tools/poseTransfer/promptInput.vue
X1627315083 c54d9fa2f6 模板组件
2025-11-13 10:56:26 +08:00

422 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, reactive, toRefs, nextTick } from "vue";
interface ContentItem {
id: string;
type: 'text' | 'input';
value: string;
placeholder?: string;
}
const props = defineProps({
content: {
type: Array,
default: () => []
}
})
const data = reactive({
// content: [
// { id: '1', type: 'text', value: '11111' },
// { id: '2', type: 'input', value: '222', placeholder: '[请输入内容]' },
// { id: '3', type: 'text', value: '333333' },
// { id: '4', type: 'input', value: '', placeholder: '[请输入内容]' }
// ] as ContentItem[]
content: props.content
})
const editableArea = ref<HTMLElement>()
const { content } = toRefs(data)
const cursorState = ref({
isContainerClick: false
})
// 检查并删除末尾的空文本框
const removeLastEmptyTextIfNeeded = () => {
const lastItem = content.value[content.value.length - 1]
if (lastItem && lastItem.type === 'text' && lastItem.value === '') {
content.value.pop()
return true
}
return false
}
// 确保末尾有空文本框
const ensureEmptyTextAtEnd = () => {
const lastItem = content.value[content.value.length - 1]
if (!lastItem || lastItem.type !== 'text' || lastItem.value !== '') {
const newItem: ContentItem = {
id: Date.now().toString(),
type: 'text',
value: ''
}
content.value.push(newItem)
return true
}
return false
}
// 元素信息获取
const getCurrentElementInfo = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
const node = range.startContainer
let element: HTMLElement | null = null
if (node.nodeType === Node.TEXT_NODE) {
element = (node as Text).parentElement
} else {
element = node as HTMLElement
}
if (!element) return null
let index = -1
let type: 'text' | 'input' = 'text'
let isAtStart = false
let isAtEnd = false
if (element.classList.contains('text-field')) {
index = parseInt(element.getAttribute('data-index') || '-1')
type = 'text'
const textContent = element.textContent || ''
isAtStart = range.startOffset === 0
isAtEnd = range.startOffset === textContent.length
} else if (element.classList.contains('input-content')) {
const parent = element.parentElement
index = parseInt(parent?.getAttribute('data-index') || '-1')
type = 'input'
const item = content.value[index]
if (element.classList.contains('has-placeholder')) {
// placeholder状态下光标在任意位置都认为是"在元素内"
isAtStart = range.startOffset === 0
isAtEnd = true
} else {
// 正常内容状态
const textContent = item.value
isAtStart = range.startOffset === 0
isAtEnd = range.startOffset === textContent.length
}
}
return { index, type, element, isAtStart, isAtEnd }
}
//光标设置
const setCursorToElement = (element: HTMLElement, position: 'start' | 'end') => {
const selection = window.getSelection()
const range = document.createRange()
if (element.classList.contains('input-content') && element.classList.contains('has-placeholder')) {
// placeholder状态的特殊处理
range.selectNodeContents(element)
range.collapse(position === 'start')
} else if (element.childNodes.length > 0) {
const targetNode = position === 'start' ? element.firstChild! : element.lastChild!
if (targetNode.nodeType === Node.TEXT_NODE) {
const offset = position === 'start' ? 0 : (targetNode.textContent || '').length
range.setStart(targetNode, offset)
range.setEnd(targetNode, offset)
} else {
range.selectNodeContents(element)
range.collapse(position === 'start')
}
} else {
range.selectNodeContents(element)
range.collapse(position === 'start')
}
selection?.removeAllRanges()
selection?.addRange(range)
}
// 键盘事件处理
const handleKeydown = (event: KeyboardEvent) => {
const elementInfo = getCurrentElementInfo()
if (!elementInfo) return
const { index, type, isAtStart, isAtEnd } = elementInfo
switch (event.key) {
case 'Backspace':
if (isAtStart && index > 0) {
event.preventDefault()
handleCrossElementDelete(index)
} else if (type === 'input' && elementInfo.element?.classList.contains('has-placeholder')) {
event.preventDefault()
}
// 其他情况让浏览器正常处理删除
break
case 'ArrowLeft':
if (isAtStart && index > 0) {
event.preventDefault()
navigateToElement(index - 1, 'end')
}
break
case 'ArrowRight':
if (isAtEnd && index < content.value.length - 1) {
event.preventDefault()
navigateToElement(index + 1, 'start')
} else if (isAtEnd && index === content.value.length - 1) {
// 在最后一个元素末尾按右箭头,确保有一个空文本框
ensureEmptyTextAtEnd()
nextTick(() => {
navigateToElement(index + 1, 'start')
})
}
break
}
}
// 跨元素删除逻辑
const handleCrossElementDelete = (currentIndex: number) => {
const prevIndex = currentIndex - 1
const prevItem = content.value[prevIndex]
if (prevItem.type === 'input') {
if (prevItem.value.trim() === '') {
// 删除空输入框
content.value.splice(prevIndex, 1)
nextTick(() => {
// 删除输入框后,先删除末尾的空文本框
removeLastEmptyTextIfNeeded()
// 然后聚焦到正确的位置
if (prevIndex < content.value.length) {
focusElement(prevIndex, 'end')
} else if (content.value.length > 0) {
focusElement(content.value.length - 1, 'end')
}
})
} else {
// 删除输入框最后一个字符,但保留输入框
const newValue = prevItem.value.slice(0, -1)
content.value[prevIndex].value = newValue
updateInputDisplay(prevIndex)
nextTick(() => focusElement(prevIndex, 'end'))
}
} else {
// 文本框:移动到前一个文本框末尾,让浏览器正常删除
// 先删除末尾的空文本框
removeLastEmptyTextIfNeeded()
focusElement(prevIndex, 'end')
}
}
// 导航到元素
const navigateToElement = (targetIndex: number, position: 'start' | 'end') => {
const targetItem = content.value[targetIndex]
const element = targetItem.type === 'text'
? editableArea.value?.querySelector(`.text-field[data-index="${targetIndex}"]`) as HTMLElement
: editableArea.value?.querySelector(`.input-field[data-index="${targetIndex}"] .input-content`) as HTMLElement
if (element) setCursorToElement(element, position)
}
// 焦点设置
const focusElement = (index: number, position: 'start' | 'end') => {
const item = content.value[index]
const element = item.type === 'text'
? editableArea.value?.querySelector(`.text-field[data-index="${index}"]`) as HTMLElement
: editableArea.value?.querySelector(`.input-field[data-index="${index}"] .input-content`) as HTMLElement
if (element) setCursorToElement(element, position)
}
// 输入框显示管理
const updateInputDisplay = (index: number) => {
const item = content.value[index]
if (item.type !== 'input') return
const inputElement = editableArea.value?.querySelector(
`.input-field[data-index="${index}"] .input-content`
) as HTMLElement
if (!inputElement) return
const showPlaceholder = item.value.trim() === '' && item.placeholder
if (showPlaceholder) {
inputElement.classList.add('has-placeholder')
inputElement.textContent = item.placeholder
} else {
inputElement.classList.remove('has-placeholder')
inputElement.textContent = item.value
}
}
// 输入框内容变化处理
const handleInputChange = (index: number, event: Event) => {
const target = event.target as HTMLSpanElement
const item = content.value[index]
// 如果当前显示placeholder不更新实际值
if (!target.classList.contains('has-placeholder')) {
const newValue = target.textContent || ''
content.value[index].value = newValue
// 如果内容变空显示placeholder
if (newValue.trim() === '' && item.placeholder) {
target.classList.add('has-placeholder')
target.textContent = item.placeholder
}
}
}
// 输入框键盘事件
const handleInputKeydown = (event: KeyboardEvent, index: number) => {
const target = event.target as HTMLSpanElement
if (event.key === 'Backspace') {
// 如果显示placeholder阻止删除
if (target.classList.contains('has-placeholder')) {
event.preventDefault()
return
}
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const isAtStart = range.startOffset === 0
// 如果光标在输入框开头阻止默认行为让外部的handleBackspace处理
if (isAtStart) {
event.preventDefault()
return
}
}
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
// 普通字符输入
if (target.classList.contains('has-placeholder')) {
event.preventDefault()
target.textContent = event.key
target.classList.remove('has-placeholder')
content.value[index].value = event.key
// 移动光标到末尾
nextTick(() => {
setCursorToElement(target, 'end')
})
}
}
}
const handleInputBlur = (index: number) => {
updateInputDisplay(index)
}
// 容器点击处理
const handleContainerClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target === editableArea.value) {
event.preventDefault()
cursorState.value.isContainerClick = true
// 确保末尾有空文本框并聚焦到它
ensureEmptyTextAtEnd()
nextTick(() => {
focusElement(content.value.length - 1, 'start')
})
}
}
// 初始化
const initPlaceholders = () => {
nextTick(() => {
content.value.forEach((_, index) => updateInputDisplay(index))
// 确保初始状态下有一个空文本框
ensureEmptyTextAtEnd()
})
}
const getFullText = () => {
return content.value.map(item => {
if (item.type === 'text') {
return item.value
} else {
// 只获取用户实际输入的值,即使为空也显示空括号
return `${item.value}`
}
}).join('')
}
onMounted(() => {
initPlaceholders()
})
defineExpose({
getFullText,
content
})
</script>
<template>
<div ref="editableArea" class="promptInput" @keydown="handleKeydown" @click="handleContainerClick">
<template v-for="(item, index) in content" :key="item.id">
<span v-if="item.type === 'text'" class="text-field" :data-index="index" contenteditable="plaintext-only">{{
item.value }}</span>
<span v-else class="input-field" :data-index="index">
<span class="input-content" contenteditable="plaintext-only" @input="(e) => handleInputChange(index, e)"
@keydown="(e) => handleInputKeydown(e, index)" @blur="() => handleInputBlur(index)"></span>
</span>
</template>
</div>
</template>
<style lang="less" scoped>
.promptInput {
--promptInputBorderRadius: 10px;
--promptInputBorder: 2px solid #dcdfe6;
--promptInputPadding: 1.5rem;
width: 100%;
min-height: 12rem;
border-radius: var(--promptInputBorderRadius);
border: var(--promptInputBorder);
padding: var(--promptInputPadding);
background: white;
line-height: 1.6;
outline: none;
white-space: pre-wrap;
user-select: text;
cursor: text;
.text-field {
display: inline;
outline: none;
padding: .2rem 0;
font-size: 1.8rem;
min-width: 2px;
/* 确保空文本框也能点击 */
}
.input-field {
display: inline-block;
background: #e3f2fd;
border: 1px solid #bbdefb;
margin: 0 .2rem;
padding: .2rem 1rem;
font-size: 1.8rem;
border-radius: 4px;
.input-content {
outline: none;
color: #1976d2;
display: inline-block;
min-width: 2rem;
&.has-placeholder {
color: #95a5a6;
font-style: italic;
}
}
}
}
</style>