diff --git a/src/views/home/components/Input.vue b/src/views/home/components/Input.vue
index 6a55191..3848b7f 100644
--- a/src/views/home/components/Input.vue
+++ b/src/views/home/components/Input.vue
@@ -2,249 +2,79 @@
-
-
+
+
diff --git a/src/views/home/components/input/InputEditor.vue b/src/views/home/components/input/InputEditor.vue
new file mode 100644
index 0000000..6cfc18a
--- /dev/null
+++ b/src/views/home/components/input/InputEditor.vue
@@ -0,0 +1,231 @@
+
+
+
+ {{ placeholder }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/home/components/input/InputImagePreviewList.vue b/src/views/home/components/input/InputImagePreviewList.vue
new file mode 100644
index 0000000..25b535a
--- /dev/null
+++ b/src/views/home/components/input/InputImagePreviewList.vue
@@ -0,0 +1,89 @@
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/home/components/input/InputToolbar.vue b/src/views/home/components/input/InputToolbar.vue
new file mode 100644
index 0000000..b68caa6
--- /dev/null
+++ b/src/views/home/components/input/InputToolbar.vue
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/views/home/components/input/ReportShortcutButton.vue b/src/views/home/components/input/ReportShortcutButton.vue
new file mode 100644
index 0000000..21154bb
--- /dev/null
+++ b/src/views/home/components/input/ReportShortcutButton.vue
@@ -0,0 +1,46 @@
+
+
+
+ {{ label }}
+
+
+
+
+
+
diff --git a/src/views/home/components/input/SettingPopover.vue b/src/views/home/components/input/SettingPopover.vue
new file mode 100644
index 0000000..c687ac7
--- /dev/null
+++ b/src/views/home/components/input/SettingPopover.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
{{ translate(item.label) }}
+
+
+ {{ item.value }}%
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/home/components/input/StyleSelect.vue b/src/views/home/components/input/StyleSelect.vue
new file mode 100644
index 0000000..ff253a8
--- /dev/null
+++ b/src/views/home/components/input/StyleSelect.vue
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
{{ item.label }}
+

+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/home/components/input/options.ts b/src/views/home/components/input/options.ts
new file mode 100644
index 0000000..9fa9fcd
--- /dev/null
+++ b/src/views/home/components/input/options.ts
@@ -0,0 +1,48 @@
+import type { OptionItem, SettingOption } from './types'
+
+export const styleKeys: string[] = [
+ 'Venetian Modern',
+ 'Coastal',
+ 'Maximalism',
+ 'Memphis',
+ 'Verdant',
+ 'Century Chrome',
+ 'Modern Revival',
+ 'Transitional',
+ "Tuscan 2000's",
+ 'Kitsch-core',
+ 'Bauhaus',
+ 'Constructivism',
+ 'Nordic Noir',
+ 'Dopamine',
+ 'Squiggle'
+]
+
+export const optionTagOrder = ['type', 'area', 'style'] as const
+
+export const createTypeOptions = (): OptionItem[] => [
+ {
+ label: 'Input.types.sofa',
+ value: 'Sofa'
+ },
+ {
+ label: 'Input.types.desk',
+ value: 'Desk'
+ },
+ {
+ label: 'Input.types.chair',
+ value: 'Chair'
+ }
+]
+
+export const createStyleOptions = (): OptionItem[] =>
+ styleKeys.map((key) => ({
+ label: key,
+ value: key
+ }))
+
+export const createSettingOptions = (): SettingOption[] => [
+ { label: 'Input.settingOptions.first', value: 50 },
+ { label: 'Input.settingOptions.second', value: 50 },
+ { label: 'Input.settingOptions.third', value: 50 }
+]
diff --git a/src/views/home/components/input/types.ts b/src/views/home/components/input/types.ts
new file mode 100644
index 0000000..b00c77e
--- /dev/null
+++ b/src/views/home/components/input/types.ts
@@ -0,0 +1,19 @@
+export interface OptionItem {
+ label: string
+ value: string
+}
+
+export interface SettingOption {
+ label: string
+ value: number
+}
+
+export interface UploadedImage {
+ url: string
+ name: string
+ path?: any
+}
+
+export type PreviewImage = UploadedImage | string
+
+export type OptionTagKind = 'type' | 'area' | 'style'
diff --git a/src/views/home/components/input/useInputEditor.ts b/src/views/home/components/input/useInputEditor.ts
new file mode 100644
index 0000000..af888cc
--- /dev/null
+++ b/src/views/home/components/input/useInputEditor.ts
@@ -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 | 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
+ }
+}
diff --git a/src/views/home/components/input/useInputImages.ts b/src/views/home/components/input/useInputImages.ts
new file mode 100644
index 0000000..58abbe7
--- /dev/null
+++ b/src/views/home/components/input/useInputImages.ts
@@ -0,0 +1,78 @@
+import { nextTick, ref } from 'vue'
+import { uploadImage } from '@/api/upload'
+import type { PreviewImage, UploadedImage } from './types'
+
+export function useInputImages(focusEditor: () => void) {
+ const uploadedImages = ref([])
+ const quoteList = ref([])
+ const showPreview = ref(false)
+ const previewUrl = ref('')
+
+ const handleFileChange = (event: Event) => {
+ const input = event.target as HTMLInputElement
+
+ if (input.files) {
+ Array.from(input.files).forEach((file) => {
+ if (file.type.startsWith('image/')) {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ uploadImage(formData).then((res) => {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ uploadedImages.value.push({
+ url: e.target?.result as string,
+ name: file.name,
+ path: res
+ })
+ }
+ reader.readAsDataURL(file)
+ })
+ }
+ })
+ }
+
+ nextTick(focusEditor)
+ input.value = ''
+ }
+
+ const removeImage = (index: number, item: PreviewImage) => {
+ if (typeof item === 'string') {
+ const quoteIndex = quoteList.value.indexOf(item)
+ if (quoteIndex > -1) {
+ quoteList.value.splice(quoteIndex, 1)
+ }
+ return
+ }
+
+ uploadedImages.value.splice(index, 1)
+ }
+
+ const previewImage = (url: string) => {
+ showPreview.value = true
+ previewUrl.value = url
+ }
+
+ const handleQuote = (url: string) => {
+ const hasQuoted = quoteList.value.includes(url)
+ if (hasQuoted) return
+ quoteList.value[0] = url
+ }
+
+ const clearImages = () => {
+ uploadedImages.value = []
+ quoteList.value = []
+ }
+
+ return {
+ uploadedImages,
+ quoteList,
+ showPreview,
+ previewUrl,
+ handleFileChange,
+ removeImage,
+ previewImage,
+ handleQuote,
+ clearImages
+ }
+}