feat: 报告标签添加后显示新的Placeholder

This commit is contained in:
2026-03-09 10:36:43 +08:00
parent 0d29e8fc44
commit be29985f3f
2 changed files with 393 additions and 25 deletions

View File

@@ -16,15 +16,38 @@
</div>
</div>
<!-- 编辑区域 -->
<div
ref="editorRef"
class="editor"
contenteditable="true"
:placeholder="$t('Input.placeholder')"
@input="handleEditorInput"
@paste="handleEditorPaste"
@keydown="handleKeyDown"
></div>
<div class="editor-wrapper">
<!-- 静态占位符 - 当编辑器为空时显示 -->
<div
v-if="showPlaceholder"
class="editor-placeholder"
:style="{
position: 'absolute',
top: '0',
left: '0',
padding: '0 1.4rem 1.4rem',
fontSize: '2rem',
fontFamily: 'InterRegular',
fontWeight: 400,
color: '#999',
pointerEvents: 'none',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
width: 'calc(100% - 2.8rem)',
boxSizing: 'border-box'
}"
>
{{ $t('Input.placeholder') }}
</div>
<div
ref="editorRef"
class="editor"
contenteditable="true"
@input="handleEditorInput"
@paste="handleEditorPaste"
@keydown="handleKeyDown"
></div>
</div>
</div>
<div class="operate flex align-center space-between">
<div class="left flex align-center">
@@ -281,14 +304,49 @@
const editorRef = ref<HTMLDivElement | null>(null)
const inputValue = ref<string>('')
const reportTags = ref([])
const customPlaceholder = ref<HTMLElement | null>(null)
// 控制占位符显示
const showPlaceholder = computed(() => {
if (!editorRef.value) return true
if (inputValue.value || reportTags.value.length > 0) return false
const editor = editorRef.value
const textContent = editor.textContent?.replace(/[\s\u200B\n]/g, '').trim() || '' // 移除空格、零宽空格、换行
const isEmptyHTML = editor.innerHTML === '' || editor.innerHTML === '<br>' // 处理浏览器 <br> 插入
const hasMeaningfulChildren = Array.from(editor.children).some(
(child) => child.tagName !== 'BR' && !child.classList.contains('editor-tag') // 排除 <br> 和 tags如果 tags 算非空)
)
// 如果纯文本为空、无有意义子元素、且不是仅剩 <br>,则显示 placeholder
return textContent === '' && !hasMeaningfulChildren && isEmptyHTML
})
// 打字机效果显示placeholder
let typewriterTimeout: ReturnType<typeof setTimeout> | null = null
const typeWriterEffect = (element: HTMLElement, text: string, index: number = 0) => {
if (index < text.length) {
element.innerText += text.charAt(index)
typewriterTimeout = setTimeout(() => {
typeWriterEffect(element, text, index + 1)
}, 30) // 每个字符间隔30ms
}
}
// 停止打字机效果
const stopTypewriter = () => {
if (typewriterTimeout) {
clearTimeout(typewriterTimeout)
typewriterTimeout = null
}
}
// 导出给父组件调用的方法
const addReportTag = (text?: string) => {
// 使用传入的文本,如果没有传入则使用默认的翻译文本
const tagText = text || t('Input.trendingReport')
// create container matching static structure: <div class="editor-tag report-btn flex-center" contenteditable="false">...
const tag = document.createElement('div')
tag.contentEditable = 'false'
const imgLeft = document.createElement('img')
@@ -313,10 +371,15 @@
imgClose.addEventListener('click', (ev) => {
ev.stopPropagation()
stopTypewriter() // 关闭标签时停止打字机效果
// remove tag when close clicked
tag.remove()
const idx = reportTags.value.indexOf(tag)
if (idx > -1) reportTags.value.splice(idx, 1)
if (customPlaceholder.value) {
customPlaceholder.value.remove()
customPlaceholder.value = null
}
})
// assemble
@@ -361,9 +424,41 @@
}
reportTags.value.push(tag)
const placeholderSpan = document.createElement('span')
placeholderSpan.className = 'custom-placeholder'
// 初始为空字符串,稍后通过打字机效果填充
placeholderSpan.innerText = ''
if (tag.parentNode) {
tag.parentNode.insertBefore(placeholderSpan, tag.nextSibling)
const zwsp = document.createTextNode('\u200B')
tag.parentNode.insertBefore(zwsp, placeholderSpan.nextSibling)
const newRange = document.createRange()
newRange.setStart(placeholderSpan, 0)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
selection.addRange(newRange)
}
customPlaceholder.value = placeholderSpan
// 打字机效果显示placeholder文本
const placeholderText =
'Generate a furniture trending report for 2025, including popular styles and design directions.'
typeWriterEffect(placeholderSpan, placeholderText)
const removePlaceholderOnInput = () => {
stopTypewriter() // 用户输入时停止打字机效果
if (placeholderSpan.parentNode) {
placeholderSpan.remove()
customPlaceholder.value = null
}
editorRef.value?.removeEventListener('input', removePlaceholderOnInput)
}
editorRef.value?.addEventListener('input', removePlaceholderOnInput)
}
const toogltReportTag = () => {
stopTypewriter() // 移除标签时停止打字机效果
// 清理掉已被删除的标签引用(从 DOM 中移除的元素)
reportTags.value = reportTags.value.filter((tag) => tag.parentNode !== null)
@@ -380,12 +475,38 @@
tag.remove()
})
reportTags.value = []
if (customPlaceholder.value) {
customPlaceholder.value.remove()
customPlaceholder.value = null
}
} else {
// 添加标签
addReportTag()
}
}
// 清理编辑器中的零宽字符
const cleanupEditor = () => {
if (!editorRef.value) return
const editor = editorRef.value
// 移除所有零宽字符
if (editor.textContent) {
const cleanedText = editor.textContent.replace(/[\u200B-\u200D\uFEFF]/g, '')
// 检查是否真的为空
if (!cleanedText.trim() && editor.children.length === 0) {
// 完全清空
editor.innerHTML = ''
editor.textContent = ''
reportTags.value = []
customPlaceholder.value = null
stopTypewriter()
}
}
}
const handleEditorInput = () => {
if (!editorRef.value) return
@@ -395,13 +516,29 @@
let node: Node | null
while ((node = walker.nextNode())) {
if (node.parentElement?.classList.contains('custom-placeholder')) continue
if (node.parentElement?.classList.contains('editor-tag')) continue
text += node.textContent
}
// 移除末尾的空格
text = text.replace(/\s+$/, '')
// 移除末尾的空格和零宽空格
text = text.replace(/[\s\u200B]+$/, '')
inputValue.value = text
// 检查编辑器是否真正为空(没有可见子元素且没有文本)
const editor = editorRef.value
const hasChildElements = editor.children.length > 0
const hasTextContent = editor.textContent?.replace(/[\s\u200B]/g, '').trim().length > 0
// 如果编辑器完全为空,清空它以显示占位符
// 同时也要清空 reportTags 和 customPlaceholder
if (!hasChildElements && !hasTextContent) {
editor.innerHTML = ''
reportTags.value = []
customPlaceholder.value = null
stopTypewriter()
}
// 自动调整高度
autoResizeEditor()
}
@@ -444,7 +581,7 @@
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
if (range.collapsed) {
let nodeToDelete = null
let nodeToDelete: Node | null = null
const startContainer = range.startContainer
const startOffset = range.startOffset
@@ -458,22 +595,59 @@
nodeToDelete = startContainer.childNodes[startOffset]
}
// 检查 nodeToDelete 是否存在以及是否为元素节点
if (
nodeToDelete &&
nodeToDelete.nodeType === Node.ELEMENT_NODE &&
(nodeToDelete as Element).classList &&
((nodeToDelete as Element).classList.contains('editor-tag') ||
(nodeToDelete as Element).classList.contains('report-tag'))
(nodeToDelete as Element).classList
) {
e.preventDefault()
;(nodeToDelete as Element).remove()
// Optional: remove from reportTags if tracking
const index = reportTags.value.indexOf(nodeToDelete)
if (index > -1) {
reportTags.value.splice(index, 1)
const element = nodeToDelete as Element
const isEditorTag = element.classList.contains('editor-tag')
const isReportTag = element.classList.contains('report-tag')
const isCustomPlaceholder = element.classList.contains('custom-placeholder')
if (isEditorTag || isReportTag || isCustomPlaceholder) {
e.preventDefault()
element.remove()
// 如果删除的是 customPlaceholder停止打字机效果
if (isCustomPlaceholder) {
stopTypewriter()
customPlaceholder.value = null
}
// 从 reportTags 中移除
const index = reportTags.value.indexOf(nodeToDelete as Element)
if (index > -1) {
reportTags.value.splice(index, 1)
}
// 删除标签后清理零宽字符并检查是否为空
nextTick(() => {
cleanupEditor()
})
return
}
return
}
nextTick(() => {
if (editorRef.value) {
const text = editorRef.value.textContent || ''
const cleanedText = text.replace(/[\u200B-\u200D\uFEFF]/g, '')
if (
editorRef.value.innerHTML === '<br>' ||
!cleanedText.trim()
) {
editorRef.value.innerHTML = ''
// 检查是否完全为空,需要显示占位符
if (editorRef.value.children.length === 0) {
reportTags.value = []
customPlaceholder.value = null
stopTypewriter()
}
}
}
})
}
}
}
@@ -510,7 +684,9 @@
// 初始化编辑器高度
onMounted(() => {
autoResizeEditor()
nextTick(() => {
autoResizeEditor()
})
})
const typeValue = ref<string>('')
@@ -644,11 +820,26 @@
}
.scroll-content {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
padding: 3.4rem 1.7rem 1.7rem;
}
.editor-wrapper {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
min-height: 0;
}
.editor-placeholder {
position: absolute;
z-index: 0;
pointer-events: none;
}
.editor {
width: 100%;
flex: 1;
@@ -868,6 +1059,11 @@
</style>
<style lang="less">
.custom-placeholder {
color: #999; // 灰色,像 placeholder
margin-left: 0.5rem; // 与标签间距
pointer-events: auto; // 允许交互
}
.fida-style-select-popover {
width: 34.2rem !important;
padding: 0 !important;