422 lines
11 KiB
Vue
422 lines
11 KiB
Vue
<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> |