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 | ComputedRef t: (key: string) => string typeValue: Ref areaValue: Ref styleValue: Ref typeOptions: Ref areaOptions: Ref styleOptions: Ref onSubmit: () => void | Promise } export function useInputEditor(options: UseInputEditorOptions) { const editorRef = ref(null) const inputValue = ref('') const reportTags = ref([]) const optionTags = ref>>({}) const reportPromptText = ref(null) let reportTypewriterTimeout: ReturnType | 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 === '
' 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 === '
' || !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 } }