Files
aida_front/src/component/home/tools/poseTransfer/promptInput.vue
2025-11-14 15:10:32 +08:00

542 lines
14 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, useTemplateRef, watch } 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 = () => {
if(assistModel.value){
return content.value.map(item => {
if (item.type === 'text') {
return item.value
} else {
// 如果 input 没有输入 value则用 placeholder 填充,并去掉首尾的 []
if (item.value) {
return ` ${item.value} `
} else if (item.placeholder) {
let placeholderText = item.placeholder
// 去掉首尾的 []
if (placeholderText.startsWith('[') && placeholderText.endsWith(']')) {
placeholderText = placeholderText.slice(1, -1)
}
return ` ${placeholderText} `
}
return ''
}
}).join('')
}
return textareaValue.value
}
const textareaValue = ref('')
const assistModel= ref(false)
const handleClickAssistBtn = () => {
assistModel.value = !assistModel.value
}
// 监听 assistModel 变化,切换到 textarea 模式时调整高度
watch(assistModel, (newVal) => {
if (!newVal) {
// 切换到 textarea 模式
nextTick(() => {
handleInputResize()
})
}
})
const textareaRef = useTemplateRef<HTMLTextAreaElement>('textareaRef')
const handleInputResize = () => {
const textarea = textareaRef.value
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
}
onMounted(() => {
initPlaceholders()
// 如果初始状态是 textarea 模式,设置初始高度
if (!assistModel.value) {
nextTick(() => {
handleInputResize()
})
}
})
defineExpose({
getFullText,
content
})
</script>
<template>
<div v-show="!assistModel" class="textarea-container">
<textarea
class="area"
v-model="textareaValue"
ref="textareaRef"
@input="handleInputResize"
:placeholder="$t('poseTransfer.PormptPlaceholder')"
/>
<div class="asistant-btn" @click="handleClickAssistBtn">
<i class="fi fi-bs-magic-wand asistant-icon"></i>
<span>{{ $t('ProductImg.PromptAssit') }}</span>
</div>
</div>
<div v-show="assistModel" 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 class="asistant-btn" @click="handleClickAssistBtn">
<i class="fi fi-bs-magic-wand asistant-icon"></i>
<span>{{ $t('ProductImg.PromptAssit') }}</span>
</div>
</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;
position: relative;
padding-bottom: 4rem;
.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;
display: inline-block;
min-width: 2rem;
&.has-placeholder {
color: #b9b9b9;
// font-style: italic;
}
}
}
}
.textarea-container{
position: relative;
border-radius: 10px;
border: 2px solid #dcdfe6;
padding: 1.5rem 1.5rem 3rem;
height: auto;
.area{
width: 100%;
min-height: 12rem;
height: auto;
background: white;
line-height: 1.6;
outline: none;
white-space: pre-wrap;
user-select: text;
cursor: text;
position: relative;
// padding-bottom: 4rem;
resize: none;
border: none;
}
}
.asistant-btn {
height: 2.3rem;
padding: 0 0.6rem;
font-size: 1rem;
font-weight: 400;
color: #313131;
position: absolute;
bottom: 1.3rem;
left: 1.3rem;
display: flex;
column-gap: 0.3rem;
justify-content: center;
align-items: center;
background-color: #f2f2f2;
border: 1px solid #dfdfdf;
border-radius: 0.5rem;
cursor: pointer;
.asistant-icon {
font-size: 1rem;
margin-right: 0;
width: initial;
// margin-top: -0.2rem;
}
}
</style>