Files
aida_front/src/component/home/tools/poseTransfer/promptInput.vue

554 lines
15 KiB
Vue
Raw Normal View History

2025-11-13 10:56:26 +08:00
<script setup lang="ts">
2025-11-18 14:44:38 +08:00
import { ref, onMounted, reactive, toRefs, nextTick, useTemplateRef } from 'vue'
2025-11-13 10:56:26 +08:00
interface ContentItem {
2025-11-18 14:44:38 +08:00
id: string
type: 'text' | 'input'
value: string
placeholder?: string
2025-11-13 10:56:26 +08:00
}
const props = defineProps({
2025-11-18 14:44:38 +08:00
content: {
type: Array,
default: () => []
}
2025-11-13 10:56:26 +08:00
})
const data = reactive({
2025-11-18 14:44:38 +08:00
// 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
2025-11-13 10:56:26 +08:00
})
const editableArea = ref<HTMLElement>()
const { content } = toRefs(data)
const cursorState = ref({
2025-11-18 14:44:38 +08:00
isContainerClick: false
2025-11-13 10:56:26 +08:00
})
// 检查并删除末尾的空文本框
const removeLastEmptyTextIfNeeded = () => {
2025-11-18 14:44:38 +08:00
const lastItem = content.value[content.value.length - 1]
if (lastItem && lastItem.type === 'text' && lastItem.value === '') {
content.value.pop()
return true
}
return false
2025-11-13 10:56:26 +08:00
}
// 确保末尾有空文本框
const ensureEmptyTextAtEnd = () => {
2025-11-18 14:44:38 +08:00
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
2025-11-13 10:56:26 +08:00
}
// 元素信息获取
const getCurrentElementInfo = () => {
2025-11-18 14:44:38 +08:00
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 }
2025-11-13 10:56:26 +08:00
}
//光标设置
const setCursorToElement = (element: HTMLElement, position: 'start' | 'end') => {
2025-11-18 14:44:38 +08:00
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)
2025-11-13 10:56:26 +08:00
}
// 键盘事件处理
const handleKeydown = (event: KeyboardEvent) => {
2025-11-18 14:44:38 +08:00
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
}
2025-11-13 10:56:26 +08:00
}
// 跨元素删除逻辑
const handleCrossElementDelete = (currentIndex: number) => {
2025-11-18 14:44:38 +08:00
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')
}
2025-11-13 10:56:26 +08:00
}
// 导航到元素
const navigateToElement = (targetIndex: number, position: 'start' | 'end') => {
2025-11-18 14:44:38 +08:00
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)
2025-11-13 10:56:26 +08:00
}
// 焦点设置
const focusElement = (index: number, position: 'start' | 'end') => {
2025-11-18 14:44:38 +08:00
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)
2025-11-13 10:56:26 +08:00
}
// 输入框显示管理
const updateInputDisplay = (index: number) => {
2025-11-18 14:44:38 +08:00
const item = content.value[index]
if (item.type !== 'input') return
2025-11-13 10:56:26 +08:00
2025-11-18 14:44:38 +08:00
const inputElement = editableArea.value?.querySelector(
`.input-field[data-index="${index}"] .input-content`
) as HTMLElement
2025-11-13 10:56:26 +08:00
2025-11-18 14:44:38 +08:00
if (!inputElement) return
2025-11-13 10:56:26 +08:00
2025-11-18 14:44:38 +08:00
const showPlaceholder = item.value.trim() === '' && item.placeholder
2025-11-13 10:56:26 +08:00
2025-11-18 14:44:38 +08:00
if (showPlaceholder) {
inputElement.classList.add('has-placeholder')
inputElement.textContent = item.placeholder
} else {
inputElement.classList.remove('has-placeholder')
inputElement.textContent = item.value
}
2025-11-13 10:56:26 +08:00
}
// 输入框内容变化处理
const handleInputChange = (index: number, event: Event) => {
2025-11-18 14:44:38 +08:00
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
}
}
2025-11-13 10:56:26 +08:00
}
// 输入框键盘事件
const handleInputKeydown = (event: KeyboardEvent, index: number) => {
2025-11-18 14:44:38 +08:00
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')
})
}
}
2025-11-13 10:56:26 +08:00
}
const handleInputBlur = (index: number) => {
2025-11-18 14:44:38 +08:00
updateInputDisplay(index)
2025-11-13 10:56:26 +08:00
}
// 容器点击处理
const handleContainerClick = (event: MouseEvent) => {
2025-11-18 14:44:38 +08:00
const target = event.target as HTMLElement
if (target === editableArea.value) {
event.preventDefault()
cursorState.value.isContainerClick = true
// 确保末尾有空文本框并聚焦到它
ensureEmptyTextAtEnd()
nextTick(() => {
focusElement(content.value.length - 1, 'start')
})
}
2025-11-13 10:56:26 +08:00
}
// 初始化
const initPlaceholders = () => {
2025-11-18 14:44:38 +08:00
nextTick(() => {
content.value.forEach((_, index) => updateInputDisplay(index))
// 确保初始状态下有一个空文本框
ensureEmptyTextAtEnd()
})
2025-11-13 10:56:26 +08:00
}
const getFullText = () => {
2025-11-18 14:44:38 +08:00
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
2025-11-14 15:10:32 +08:00
}
const textareaValue = ref('')
2025-11-18 14:44:38 +08:00
const assistModel = ref(false)
2025-11-14 15:10:32 +08:00
const handleClickAssistBtn = () => {
2025-11-18 14:44:38 +08:00
assistModel.value = !assistModel.value
2025-11-14 15:10:32 +08:00
}
const textareaRef = useTemplateRef<HTMLTextAreaElement>('textareaRef')
2025-11-13 10:56:26 +08:00
onMounted(() => {
2025-11-18 14:44:38 +08:00
initPlaceholders()
2025-11-13 10:56:26 +08:00
})
defineExpose({
2025-11-18 14:44:38 +08:00
getFullText,
content
2025-11-13 10:56:26 +08:00
})
</script>
<template>
2025-11-18 14:44:38 +08:00
<div v-show="!assistModel" class="textarea-container">
<textarea
class="area"
v-model="textareaValue"
ref="textareaRef"
: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"
>
<div class="promptinput-wrapper">
<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>
<div class="asistant-btn" @click="handleClickAssistBtn">
<i class="fi fi-bs-magic-wand asistant-icon"></i>
<span>{{ $t('ProductImg.PromptAssit') }}</span>
</div>
</div>
2025-11-13 10:56:26 +08:00
</template>
<style lang="less" scoped>
.promptInput {
2025-11-18 14:44:38 +08:00
--promptInputBorderRadius: 10px;
--promptInputBorder: 2px solid #dcdfe6;
--promptInputPadding: 1.5rem;
2025-11-13 10:56:26 +08:00
width: 100%;
2025-11-18 14:44:38 +08:00
font-weight: 500;
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;
2025-11-14 15:10:32 +08:00
padding-bottom: 4rem;
2025-11-18 14:44:38 +08:00
box-sizing: content-box;
2025-11-13 10:56:26 +08:00
2025-11-18 14:44:38 +08:00
.promptinput-wrapper {
min-height: 12rem;
max-height: 14rem;
overflow-y: auto;
2025-11-13 10:56:26 +08:00
}
2025-11-18 14:44:38 +08:00
.text-field {
display: inline;
outline: none;
padding: 0.2rem 0;
font-size: 1.8rem;
min-width: 2px;
font-weight: 400;
/* 确保空文本框也能点击 */
}
.input-field {
display: inline-block;
// background: #e3f2fd;
// border: 1px solid #bbdefb;
margin: 0 0.2rem;
padding: 0.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;
}
}
}
2025-11-13 10:56:26 +08:00
}
2025-11-14 15:10:32 +08:00
2025-11-18 14:44:38 +08:00
.textarea-container {
position: relative;
border-radius: 10px;
border: 2px solid #dcdfe6;
padding: 1.5rem 1.5rem 3rem;
height: auto;
.area {
2025-11-14 15:10:32 +08:00
width: 100%;
min-height: 12rem;
2025-11-18 14:44:38 +08:00
max-height: 14rem;
2025-11-14 15:10:32 +08:00
background: white;
line-height: 1.6;
outline: none;
white-space: pre-wrap;
user-select: text;
cursor: text;
position: relative;
resize: none;
border: none;
2025-11-18 14:44:38 +08:00
overflow-y: auto;
2025-11-14 15:10:32 +08:00
}
}
.asistant-btn {
2025-11-18 14:44:38 +08:00
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;
}
2025-11-14 15:10:32 +08:00
}
2025-11-18 14:44:38 +08:00
</style>