528 lines
14 KiB
TypeScript
528 lines
14 KiB
TypeScript
|
|
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
|
||
|
|
}
|
||
|
|
}
|