chore: 拆分input组件
This commit is contained in:
527
src/views/home/components/input/useInputEditor.ts
Normal file
527
src/views/home/components/input/useInputEditor.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import lightIcon from '@/assets/images/light-icon.png'
|
||||
import closeIcon from '@/assets/images/close-icon.png'
|
||||
import TypeIcon from '@/assets/icons/TypeIcon.svg'
|
||||
import RegionIcon from '@/assets/icons/RegionIcon.svg'
|
||||
import StyleIcon from '@/assets/icons/StyleIcon.svg'
|
||||
import restoreIcon from '@/assets/images/restore.png'
|
||||
import restoreCloseIcon from '@/assets/images/tag-close.png'
|
||||
import { optionTagOrder } from './options'
|
||||
import type { OptionItem, OptionTagKind } from './types'
|
||||
|
||||
interface UseInputEditorOptions {
|
||||
isAgentMode: Ref<boolean> | ComputedRef<boolean>
|
||||
t: (key: string) => string
|
||||
typeValue: Ref<string>
|
||||
areaValue: Ref<string>
|
||||
styleValue: Ref<string>
|
||||
typeOptions: Ref<OptionItem[]>
|
||||
areaOptions: Ref<OptionItem[]>
|
||||
styleOptions: Ref<OptionItem[]>
|
||||
onSubmit: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function useInputEditor(options: UseInputEditorOptions) {
|
||||
const editorRef = ref<HTMLDivElement | null>(null)
|
||||
const inputValue = ref<string>('')
|
||||
const reportTags = ref<HTMLElement[]>([])
|
||||
const optionTags = ref<Partial<Record<OptionTagKind, HTMLElement>>>({})
|
||||
const reportPromptText = ref<Text | null>(null)
|
||||
let reportTypewriterTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const setEditorElement = (element: HTMLDivElement | null) => {
|
||||
editorRef.value = element
|
||||
}
|
||||
|
||||
const focusEditor = () => {
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
const stopReportTypewriter = () => {
|
||||
if (reportTypewriterTimeout) {
|
||||
clearTimeout(reportTypewriterTimeout)
|
||||
reportTypewriterTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
const moveCaretToTextEnd = (textNode: Text) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.anchorNode !== textNode) return
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(textNode, textNode.data.length)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
const typeReportPromptText = (textNode: Text, text: string, index = 0) => {
|
||||
if (reportPromptText.value !== textNode) return
|
||||
|
||||
if (index >= text.length) {
|
||||
reportTypewriterTimeout = null
|
||||
return
|
||||
}
|
||||
|
||||
textNode.textContent = `${textNode.textContent || ''}${text.charAt(index)}`
|
||||
handleEditorInput()
|
||||
moveCaretToTextEnd(textNode)
|
||||
|
||||
reportTypewriterTimeout = setTimeout(() => {
|
||||
typeReportPromptText(textNode, text, index + 1)
|
||||
}, 30)
|
||||
}
|
||||
|
||||
const removeReportPromptText = () => {
|
||||
stopReportTypewriter()
|
||||
const promptText = reportPromptText.value
|
||||
if (promptText?.parentNode) {
|
||||
const nextNode = promptText.nextSibling
|
||||
if (
|
||||
nextNode &&
|
||||
nextNode.nodeType === Node.TEXT_NODE &&
|
||||
nextNode.textContent === '\u200B'
|
||||
) {
|
||||
nextNode.remove()
|
||||
}
|
||||
promptText.parentNode.removeChild(promptText)
|
||||
}
|
||||
reportPromptText.value = null
|
||||
}
|
||||
|
||||
const removeNodeWithNextSpacer = (node: Node) => {
|
||||
const nextNode = node.nextSibling
|
||||
if (nextNode && nextNode.nodeType === Node.TEXT_NODE && nextNode.textContent === '\u200B') {
|
||||
nextNode.remove()
|
||||
}
|
||||
node.parentNode?.removeChild(node)
|
||||
}
|
||||
|
||||
const hasOptionTags = () => {
|
||||
return Object.values(optionTags.value).some((tag) => tag?.parentNode)
|
||||
}
|
||||
|
||||
const getOptionTagValue = (kind: OptionTagKind) => {
|
||||
if (kind === 'type') return options.typeValue.value
|
||||
if (kind === 'area') return options.areaValue.value
|
||||
return options.styleValue.value
|
||||
}
|
||||
|
||||
const getOptionTagIcon = (kind: OptionTagKind) => {
|
||||
if (kind === 'type') return TypeIcon
|
||||
if (kind === 'area') return RegionIcon
|
||||
return StyleIcon
|
||||
}
|
||||
|
||||
const getTranslatedOptionLabel = (items: OptionItem[], value: string) => {
|
||||
const option = items.find((item) => item.value === value)
|
||||
return option ? options.t(option.label) : value
|
||||
}
|
||||
|
||||
const getOptionTagLabel = (kind: OptionTagKind) => {
|
||||
const value = getOptionTagValue(kind)
|
||||
if (kind === 'type') return getTranslatedOptionLabel(options.typeOptions.value, value)
|
||||
if (kind === 'area') return getTranslatedOptionLabel(options.areaOptions.value, value)
|
||||
return options.styleOptions.value.find((item) => item.value === value)?.label || value
|
||||
}
|
||||
|
||||
const removeOptionTag = (kind: OptionTagKind) => {
|
||||
const tag = optionTags.value[kind]
|
||||
if (tag?.parentNode) {
|
||||
removeNodeWithNextSpacer(tag)
|
||||
}
|
||||
delete optionTags.value[kind]
|
||||
}
|
||||
|
||||
const removeAllOptionTags = () => {
|
||||
optionTagOrder.forEach(removeOptionTag)
|
||||
optionTags.value = {}
|
||||
}
|
||||
|
||||
const clearOptionTagValue = (kind: OptionTagKind) => {
|
||||
if (kind === 'type') {
|
||||
options.typeValue.value = ''
|
||||
} else if (kind === 'area') {
|
||||
options.areaValue.value = ''
|
||||
} else {
|
||||
options.styleValue.value = ''
|
||||
}
|
||||
|
||||
removeOptionTag(kind)
|
||||
handleEditorInput()
|
||||
}
|
||||
|
||||
const getOptionTagInsertBefore = () => {
|
||||
const editor = editorRef.value
|
||||
if (!editor) return null
|
||||
|
||||
for (const child of Array.from(editor.childNodes)) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const childKind = (child as HTMLElement).dataset.optionTagKind as OptionTagKind
|
||||
if (childKind) continue
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const createOptionTag = (kind: OptionTagKind, label: string) => {
|
||||
const tag = document.createElement('div')
|
||||
tag.contentEditable = 'false'
|
||||
tag.className = 'editor-tag option-tag flex-center'
|
||||
tag.dataset.optionTagKind = kind
|
||||
|
||||
const icon = document.createElement('img')
|
||||
icon.className = 'option-tag-icon'
|
||||
icon.src = getOptionTagIcon(kind) as unknown as string
|
||||
|
||||
const textSpan = document.createElement('span')
|
||||
textSpan.className = 'option-tag-text'
|
||||
textSpan.innerText = label
|
||||
|
||||
const close = document.createElement('img')
|
||||
close.className = 'close-icon option-close'
|
||||
close.src = closeIcon as unknown as string
|
||||
close.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
clearOptionTagValue(kind)
|
||||
})
|
||||
|
||||
tag.appendChild(icon)
|
||||
tag.appendChild(textSpan)
|
||||
tag.appendChild(close)
|
||||
|
||||
return tag
|
||||
}
|
||||
|
||||
const syncOptionTag = (kind: OptionTagKind) => {
|
||||
const value = getOptionTagValue(kind)
|
||||
if (!value) {
|
||||
removeOptionTag(kind)
|
||||
handleEditorInput()
|
||||
return
|
||||
}
|
||||
|
||||
const label = getOptionTagLabel(kind)
|
||||
const existingTag = optionTags.value[kind]
|
||||
|
||||
if (existingTag?.parentNode) {
|
||||
const text = existingTag.querySelector('.option-tag-text')
|
||||
if (text) text.textContent = label
|
||||
handleEditorInput()
|
||||
return
|
||||
}
|
||||
|
||||
const editor = editorRef.value
|
||||
if (!editor) return
|
||||
|
||||
const tag = createOptionTag(kind, label)
|
||||
const referenceNode = getOptionTagInsertBefore()
|
||||
if (referenceNode) {
|
||||
editor.insertBefore(tag, referenceNode)
|
||||
} else {
|
||||
editor.appendChild(tag)
|
||||
}
|
||||
|
||||
if (tag.parentNode) {
|
||||
tag.parentNode.insertBefore(document.createTextNode('\u200B'), tag.nextSibling)
|
||||
}
|
||||
|
||||
optionTags.value[kind] = tag
|
||||
handleEditorInput()
|
||||
}
|
||||
|
||||
const syncAllOptionTags = () => {
|
||||
optionTagOrder.forEach(syncOptionTag)
|
||||
}
|
||||
|
||||
const showPlaceholder = computed(() => {
|
||||
if (!editorRef.value) return true
|
||||
if (inputValue.value || reportTags.value.length > 0 || hasOptionTags()) return false
|
||||
|
||||
const editor = editorRef.value
|
||||
const textContent = editor.textContent?.replace(/[\s\u200B\n]/g, '').trim() || ''
|
||||
const isEmptyHTML = editor.innerHTML === '' || editor.innerHTML === '<br>'
|
||||
const hasMeaningfulChildren = Array.from(editor.children).some(
|
||||
(child) => child.tagName !== 'BR' && !child.classList.contains('editor-tag')
|
||||
)
|
||||
|
||||
return textContent === '' && !hasMeaningfulChildren && isEmptyHTML
|
||||
})
|
||||
|
||||
const addReportTag = (text?: string) => {
|
||||
const tagText = text || options.t('Input.trendingReport')
|
||||
|
||||
const tag = document.createElement('div')
|
||||
tag.contentEditable = 'false'
|
||||
const imgLeft = document.createElement('img')
|
||||
const imgClose = document.createElement('img')
|
||||
const textSpan = document.createElement('span')
|
||||
imgClose.className = 'close-icon'
|
||||
if (text) {
|
||||
tag.className = 'editor-tag restore flex-center'
|
||||
imgLeft.className = 'restore-icon'
|
||||
imgLeft.src = restoreIcon as unknown as string
|
||||
imgClose.src = restoreCloseIcon as unknown as string
|
||||
imgClose.className = 'close-icon restore'
|
||||
textSpan.className = 'restore-text'
|
||||
} else {
|
||||
tag.className = 'editor-tag report-btn flex-center'
|
||||
imgLeft.className = 'light-icon'
|
||||
imgLeft.src = lightIcon as unknown as string
|
||||
imgClose.src = closeIcon as unknown as string
|
||||
}
|
||||
|
||||
textSpan.innerText = tagText
|
||||
|
||||
imgClose.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
removeReportPromptText()
|
||||
tag.remove()
|
||||
const idx = reportTags.value.indexOf(tag)
|
||||
if (idx > -1) reportTags.value.splice(idx, 1)
|
||||
handleEditorInput()
|
||||
})
|
||||
|
||||
tag.appendChild(imgLeft)
|
||||
tag.appendChild(textSpan)
|
||||
tag.appendChild(imgClose)
|
||||
|
||||
const selection = window.getSelection()
|
||||
const isInEditor =
|
||||
editorRef.value && selection && editorRef.value.contains(selection.anchorNode)
|
||||
|
||||
if (isInEditor && selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
range.insertNode(tag)
|
||||
focusEditor()
|
||||
} else if (editorRef.value) {
|
||||
editorRef.value.appendChild(tag)
|
||||
focusEditor()
|
||||
}
|
||||
|
||||
reportTags.value.push(tag)
|
||||
const promptText = document.createTextNode('')
|
||||
if (tag.parentNode) {
|
||||
tag.parentNode.insertBefore(promptText, tag.nextSibling)
|
||||
const zwsp = document.createTextNode('\u200B')
|
||||
tag.parentNode.insertBefore(zwsp, promptText.nextSibling)
|
||||
const newRange = document.createRange()
|
||||
newRange.setStart(promptText, promptText.data.length)
|
||||
newRange.collapse(true)
|
||||
const currentSelection = window.getSelection()
|
||||
currentSelection?.removeAllRanges()
|
||||
currentSelection?.addRange(newRange)
|
||||
}
|
||||
reportPromptText.value = promptText
|
||||
typeReportPromptText(promptText, options.t('Input.reportPlaceholder'))
|
||||
}
|
||||
|
||||
const toggleReportTag = (clear = false) => {
|
||||
const shouldClear = clear === true
|
||||
reportTags.value = reportTags.value.filter((tag) => tag.parentNode !== null)
|
||||
|
||||
if (shouldClear) {
|
||||
removeReportPromptText()
|
||||
reportTags.value.forEach((tag) => {
|
||||
tag.remove()
|
||||
})
|
||||
reportTags.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (reportTags.value.length > 0) {
|
||||
removeReportPromptText()
|
||||
reportTags.value.forEach((tag) => {
|
||||
if (
|
||||
tag.nextSibling &&
|
||||
tag.nextSibling.nodeType === Node.TEXT_NODE &&
|
||||
tag.nextSibling.textContent === '\u200B'
|
||||
) {
|
||||
tag.nextSibling.remove()
|
||||
}
|
||||
tag.remove()
|
||||
})
|
||||
reportTags.value = []
|
||||
handleEditorInput()
|
||||
} 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 = []
|
||||
removeAllOptionTags()
|
||||
stopReportTypewriter()
|
||||
reportPromptText.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const autoResizeEditor = () => {
|
||||
if (options.isAgentMode.value) return
|
||||
}
|
||||
|
||||
const handleEditorInput = () => {
|
||||
if (!editorRef.value) return
|
||||
|
||||
let text = ''
|
||||
const walker = document.createTreeWalker(editorRef.value, NodeFilter.SHOW_TEXT, null)
|
||||
|
||||
let node: Node | null
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.parentElement?.closest('.editor-tag')) continue
|
||||
text += node.textContent
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (!hasChildElements && !hasTextContent) {
|
||||
editor.innerHTML = ''
|
||||
reportTags.value = []
|
||||
removeAllOptionTags()
|
||||
stopReportTypewriter()
|
||||
reportPromptText.value = null
|
||||
}
|
||||
|
||||
autoResizeEditor()
|
||||
}
|
||||
|
||||
const handleEditorPaste = (e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData?.getData('text/plain') || ''
|
||||
document.execCommand('insertText', false, text)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
options.onSubmit()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Backspace') return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount <= 0) return
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!range.collapsed) return
|
||||
|
||||
let nodeToDelete: Node | null = null
|
||||
const startContainer = range.startContainer
|
||||
const startOffset = range.startOffset
|
||||
|
||||
if (startContainer.nodeType === Node.TEXT_NODE) {
|
||||
const textNode = startContainer as Text
|
||||
if (startOffset === textNode.length) {
|
||||
nodeToDelete = textNode.nextSibling
|
||||
}
|
||||
} else if (startContainer.nodeType === Node.ELEMENT_NODE) {
|
||||
nodeToDelete = startContainer.childNodes[startOffset]
|
||||
}
|
||||
|
||||
if (
|
||||
nodeToDelete &&
|
||||
nodeToDelete.nodeType === Node.ELEMENT_NODE &&
|
||||
(nodeToDelete as Element).classList
|
||||
) {
|
||||
const element = nodeToDelete as Element
|
||||
const isEditorTag = element.classList.contains('editor-tag')
|
||||
const isReportTag = element.classList.contains('report-tag')
|
||||
const optionTagKind = (element as HTMLElement).dataset.optionTagKind as OptionTagKind
|
||||
|
||||
if (optionTagKind) {
|
||||
e.preventDefault()
|
||||
clearOptionTagValue(optionTagKind)
|
||||
nextTick(() => {
|
||||
cleanupEditor()
|
||||
handleEditorInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditorTag || isReportTag) {
|
||||
e.preventDefault()
|
||||
if (reportTags.value.includes(element as HTMLElement)) {
|
||||
removeReportPromptText()
|
||||
}
|
||||
element.remove()
|
||||
|
||||
const index = reportTags.value.indexOf(nodeToDelete as HTMLElement)
|
||||
if (index > -1) {
|
||||
reportTags.value.splice(index, 1)
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
cleanupEditor()
|
||||
handleEditorInput()
|
||||
})
|
||||
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 = []
|
||||
reportPromptText.value = null
|
||||
}
|
||||
}
|
||||
handleEditorInput()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearEditorText = () => {
|
||||
if (editorRef.value) {
|
||||
editorRef.value.innerHTML = ''
|
||||
}
|
||||
inputValue.value = ''
|
||||
}
|
||||
|
||||
const resetOptionTags = () => {
|
||||
optionTags.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
editorRef,
|
||||
inputValue,
|
||||
reportTags,
|
||||
showPlaceholder,
|
||||
setEditorElement,
|
||||
focusEditor,
|
||||
stopReportTypewriter,
|
||||
addReportTag,
|
||||
toggleReportTag,
|
||||
handleEditorInput,
|
||||
handleEditorPaste,
|
||||
handleKeyDown,
|
||||
autoResizeEditor,
|
||||
syncOptionTag,
|
||||
syncAllOptionTags,
|
||||
clearEditorText,
|
||||
resetOptionTags
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user