chore: 拆分input组件

This commit is contained in:
2026-04-30 14:18:19 +08:00
parent c3d26bdb49
commit 6098993bb3
12 changed files with 1958 additions and 1520 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
<template>
<div class="agent-operate flex flex-center">
<el-popover placement="top" trigger="click" popper-class="agent-plus-popover">
<template #reference>
<SvgIcon name="plus" color="#0D0D0D" size="16" />
</template>
<template #default>
<div class="agent-modal flex flex-col">
<div class="file flex align-center" @click="emit('upload')">
<img src="@/assets/icons/attach.svg" class="file-icon" />
<span>Upload files</span>
</div>
<div class="gap"></div>
<div class="report flex align-center" @click="emit('toggle-report')">
<SvgIcon color="#5A5A5A" name="light" size="11" />
<span>Trending report</span>
</div>
</div>
</template>
</el-popover>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: 'upload'): void
(e: 'toggle-report'): void
}>()
</script>
<style lang="less" scoped>
.agent-operate {
width: 3.2rem;
height: 3.2rem;
cursor: pointer;
border-radius: 50%;
&:hover {
background-color: #f0f0f0;
}
}
</style>
<style lang="less">
.agent-plus-popover {
display: flex;
justify-content: flex-start;
background: transparent !important;
box-shadow: none !important;
border: none !important;
padding: 0 !important;
margin-bottom: -1rem;
width: fit-content !important;
min-width: initial !important;
.el-popper__arrow:before {
display: none;
}
.agent-modal {
row-gap: 1.2rem;
font-family: 'Medium';
font-weight: 500;
font-size: 1.3rem;
color: #222;
background-color: #fff;
border: 1px solid #d9d9d9;
box-shadow: 0px 6.53px 32.63px 0px #0000000d;
border-radius: 1rem;
padding: 1.2rem 1.4rem;
transform: translateX(calc(50% - 1.6rem));
.c-svg {
width: initial;
width: 1rem;
height: 1.3rem;
}
.file,
.report {
line-height: 1.8rem;
column-gap: 1rem;
cursor: pointer;
}
.file {
.file-icon {
width: 1.1rem;
height: 1.3rem;
}
}
.gap {
height: 0.05rem;
background-color: #d4d4d4;
}
}
}
</style>

View File

@@ -0,0 +1,231 @@
<template>
<div class="editor-wrapper" :class="{ agent: isAgentMode }">
<div v-if="showPlaceholder" class="editor-placeholder">
{{ placeholder }}
</div>
<div
ref="editorRef"
class="editor"
contenteditable="true"
@input="emit('input')"
@paste="emit('paste', $event)"
@keydown="emit('keydown', $event)"
></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
withDefaults(
defineProps<{
showPlaceholder?: boolean
placeholder: string
isAgentMode?: boolean
}>(),
{
showPlaceholder: false,
isAgentMode: false
}
)
const emit = defineEmits<{
(e: 'ready', element: HTMLDivElement | null): void
(e: 'input'): void
(e: 'paste', event: ClipboardEvent): void
(e: 'keydown', event: KeyboardEvent): void
}>()
const editorRef = ref<HTMLDivElement | null>(null)
const focus = () => {
editorRef.value?.focus()
}
onMounted(() => {
emit('ready', editorRef.value)
})
defineExpose({
editorRef,
focus
})
</script>
<style lang="less" scoped>
.editor-wrapper {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
min-height: 0;
.editor-placeholder {
position: absolute;
z-index: 0;
pointer-events: none;
top: 0;
left: 0;
padding: 0 1.4rem 1.4rem;
font-size: 2rem;
font-family: 'InterRegular';
font-weight: 400;
color: #999;
white-space: pre-wrap;
word-wrap: break-word;
width: calc(100% - 2.8rem);
box-sizing: border-box;
}
.editor {
width: 100%;
flex: 1;
border: none;
outline: none;
padding: 0 1.4rem 1.4rem;
font-size: 1.8rem;
font-family: 'InterRegular';
font-weight: 400;
color: #000000;
overflow-y: auto;
overflow-x: hidden;
line-height: 2.8rem;
white-space: pre-wrap;
word-wrap: break-word;
&::-webkit-scrollbar {
width: 0;
display: none;
}
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-track {
display: none;
}
&:empty::before {
content: attr(placeholder);
color: #999;
pointer-events: none;
}
}
&.agent {
.editor {
font-family: 'Regular';
font-weight: 400;
font-size: 1.4rem;
min-height: 5rem;
max-height: initial;
padding: 0;
height: 100%;
line-height: 1.4rem;
}
.editor-placeholder {
font-family: 'Regular';
font-size: 1.4rem;
padding: 0;
line-height: 1.4rem;
}
}
}
</style>
<style lang="less">
.assist-input-wrapper .editor .editor-tag {
width: 15.6rem;
height: 3.1rem;
display: inline-flex;
border: 1px solid #0000001a;
font-weight: 500;
font-size: 1.3rem;
column-gap: 0;
margin: 0 0.5rem;
vertical-align: middle;
border-radius: 2.2rem;
&.restore {
width: auto;
max-width: 100%;
display: inline-flex;
border: none;
border-radius: 0.4rem;
background-color: rgba(0, 0, 0, 0.05);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
align-items: center;
justify-content: space-between;
padding: 0 0.9rem 0 0.7rem;
box-sizing: border-box;
}
&.option-tag {
width: auto;
max-width: 24rem;
height: 3.4rem;
padding: 0 1rem;
box-sizing: border-box;
align-items: center;
column-gap: 0.8rem;
color: #0d0d0d;
.option-tag-icon {
width: 1.2rem;
height: 1.2rem;
flex-shrink: 0;
}
.option-tag-text {
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.option-close {
width: 0.7rem;
height: 0.7rem;
margin-left: 0.2rem;
}
}
span {
margin: 0 0.7rem 0 1.2rem;
&.restore-text {
flex: 1;
min-width: 0;
max-width: 52rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.light-icon {
width: 0.9rem;
height: 1.2rem;
flex-shrink: 0;
}
.close-icon {
width: 0.9rem;
height: 0.9rem;
cursor: pointer;
flex-shrink: 0;
&.restore {
width: 0.5rem;
height: 0.5rem;
}
}
.restore-icon {
width: 1.2rem;
height: 1.2rem;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div v-if="previewItems.length > 0" class="image-preview-list flex wrap">
<div
v-for="(image, index) in previewItems"
:key="`${index}-${getPreviewUrl(image)}`"
class="image-preview-item"
>
<img
:src="getPreviewUrl(image)"
class="preview-image"
@click="emit('preview', getPreviewUrl(image))"
/>
<div class="image-remove-btn" @click="emit('remove', index, image)">
<SvgIcon name="delete" size="16" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PreviewImage, UploadedImage } from './types'
const props = defineProps<{
uploadedImages: UploadedImage[]
quoteList: string[]
}>()
const emit = defineEmits<{
(e: 'preview', url: string): void
(e: 'remove', index: number, image: PreviewImage): void
}>()
const previewItems = computed<PreviewImage[]>(() => [
...props.uploadedImages,
...props.quoteList
])
const getPreviewUrl = (image: PreviewImage) => {
return typeof image === 'string' ? image : image.url
}
</script>
<style lang="less" scoped>
.image-preview-list {
padding: 0 1.4rem 1rem;
column-gap: 1rem;
max-height: 15rem;
overflow-y: auto;
flex-shrink: 0;
.image-preview-item {
position: relative;
width: 8.6rem;
height: 8.6rem;
border-radius: 1.5rem;
overflow: hidden;
flex-shrink: 0;
border: 0.1rem solid #cdcdcd;
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.8rem;
cursor: pointer;
}
.image-remove-btn {
position: absolute;
top: 0.6rem;
right: 0.6rem;
width: 1.6rem;
height: 1.6rem;
color: #fff;
border-radius: 50%;
align-items: center;
justify-content: center;
font-size: 1.2rem;
cursor: pointer;
display: none;
}
&:hover .image-remove-btn {
display: flex;
}
}
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<div class="operate input-toolbar flex align-center space-between" :class="{ agent: isAgentMode }">
<div class="left flex align-center">
<AgentOperatePopover
v-if="isAgentMode"
@upload="triggerFileUpload"
@toggle-report="emit('toggle-report')"
/>
<div
v-if="!isAgentMode"
class="attach flex flex-center"
@click="triggerFileUpload"
>
<img src="@/assets/icons/attach.svg" />
</div>
<input
ref="fileInputRef"
type="file"
accept="image/*"
style="display: none"
@change="emit('file-change', $event)"
/>
<el-select
v-if="!isAgentMode"
v-model="typeModel"
:placeholder="typePlaceholder"
>
<el-option
v-for="item in typeOptions"
class="input-option"
:key="item.value"
:label="translate(item.label)"
:value="item.value"
/>
</el-select>
<el-select
v-if="!isAgentMode"
v-model="areaModel"
:placeholder="areaPlaceholder"
>
<el-option
v-for="item in areaOptions"
class="input-option"
:key="item.value"
:label="translate(item.label)"
:value="item.value"
/>
</el-select>
<StyleSelect
v-if="!isAgentMode"
v-model="styleModel"
:type-value="typeValue"
:options="styleOptions"
:placeholder="stylePlaceholder"
:title="styleTitle"
:confirm-text="confirmText"
/>
<SettingPopover
v-model:options="settingOptionsModel"
:title="settingTitle"
:translate="translate"
/>
</div>
<div class="right">
<div
v-if="!isAgentMode"
class="create-btn flex flex-center"
@click="emit('create')"
>
<img src="@/assets/images/shining.png" class="shining-icon" alt="" />
<span class="create-btn-text">{{ createText }}</span>
</div>
<div v-else class="sender-btn flex flex-center" @click="emit('send')">
<img
v-show="!generating"
src="@/assets/images/sender.png"
alt=""
class="sender-icon"
/>
<div v-show="generating" class="sender-pause" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import AgentOperatePopover from './AgentOperatePopover.vue'
import SettingPopover from './SettingPopover.vue'
import StyleSelect from './StyleSelect.vue'
import type { OptionItem, SettingOption } from './types'
const props = withDefaults(
defineProps<{
isAgentMode?: boolean
generating?: boolean
typeValue: string
areaValue: string
styleValue: string
typeOptions: OptionItem[]
areaOptions: OptionItem[]
styleOptions: OptionItem[]
settingOptions: SettingOption[]
typePlaceholder: string
areaPlaceholder: string
stylePlaceholder: string
styleTitle: string
settingTitle: string
confirmText: string
createText: string
translate: (key: string) => string
}>(),
{
isAgentMode: false,
generating: false
}
)
const emit = defineEmits<{
(e: 'update:typeValue', value: string): void
(e: 'update:areaValue', value: string): void
(e: 'update:styleValue', value: string): void
(e: 'update:settingOptions', value: SettingOption[]): void
(e: 'file-change', event: Event): void
(e: 'toggle-report'): void
(e: 'create'): void
(e: 'send'): void
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const typeModel = computed({
get: () => props.typeValue,
set: (value: string) => emit('update:typeValue', value)
})
const areaModel = computed({
get: () => props.areaValue,
set: (value: string) => emit('update:areaValue', value)
})
const styleModel = computed({
get: () => props.styleValue,
set: (value: string) => emit('update:styleValue', value)
})
const settingOptionsModel = computed({
get: () => props.settingOptions,
set: (value: SettingOption[]) => emit('update:settingOptions', value)
})
const triggerFileUpload = () => {
fileInputRef.value?.click()
}
</script>
<style lang="less" scoped>
.input-toolbar {
flex-shrink: 0;
margin-top: auto;
padding: 0 1.7rem 1.7rem;
.left {
column-gap: 2rem;
}
.attach {
width: 4rem;
height: 4rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
cursor: pointer;
img {
width: 1.65rem;
height: 1.86rem;
}
}
.el-select {
width: 13.9rem;
height: 4rem;
:deep(.el-select__wrapper) {
border-radius: 0.8rem;
height: 100%;
box-shadow: none;
border: 0.1rem solid rgba(0, 0, 0, 0.1);
font-weight: 500;
font-size: 1.4rem;
min-height: initial;
.el-select__placeholder {
color: #000;
}
.el-select__icon {
color: #000;
}
}
}
.create-btn {
background-color: #ff7a51;
height: 4rem;
width: 13rem;
color: #fff;
border-radius: 4.2rem;
font-family: 'SemiBold';
font-weight: 600;
font-size: 1.3rem;
cursor: pointer;
column-gap: 0.3rem;
.shining-icon {
width: 1.4rem;
height: 1.4rem;
}
}
&.agent {
padding: 1.2rem 0 0;
margin: 0;
.right {
display: flex;
align-items: center;
.sender-btn {
width: 3.2rem;
height: 3.2rem;
cursor: pointer;
background-color: #ff7a51;
border-radius: 50%;
&:hover {
background-color: #f8693d;
}
.sender-icon {
width: 1.3rem;
height: 1.3rem;
}
.sender-pause {
width: 1rem;
height: 1rem;
background-color: #fff;
}
}
}
}
}
</style>
<style lang="less">
.input-option {
padding-left: 2rem !important;
color: #0d0d0d;
font-weight: 510;
font-size: 1.3rem;
height: 3rem;
line-height: 3rem;
&.el-select-dropdown__item.is-hovering {
background-color: rgba(13, 13, 13, 0.05);
}
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div
class="report-btn flex space-between align-center outer"
:class="{ 'is-cn': isCn }"
@click="emit('click')"
>
<SvgIcon class="light-icon" color="#FFDB56" name="light" size="16" />
<span>{{ label }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
isCn: boolean
label: string
}>()
const emit = defineEmits<{
(e: 'click'): void
}>()
</script>
<style lang="less" scoped>
.report-btn {
position: absolute;
bottom: -7.4rem;
height: 4.4rem;
border-radius: 2.2rem;
width: 19.7rem;
padding: 0 2rem;
font-size: 1.8rem;
background-color: #fff;
border: 1.1px solid #f6f4ef1a;
cursor: pointer;
&.outer.is-cn {
justify-content: center;
column-gap: 3rem;
}
.c-svg {
width: 1.5rem;
height: 2rem;
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<el-popover
v-model:visible="settingPopupVisible"
placement="top"
:width="342"
:show-arrow="false"
trigger="click"
popper-class="fida-setting-popover"
>
<template #reference>
<img src="@/assets/images/setting.png" class="setting-icon" />
</template>
<div class="fida-setting-popover-content flex flex-col">
<div class="fida-setting-popover-header">
{{ title }}
</div>
<div class="fida-setting-slider-list">
<div
v-for="(item, index) in options"
:key="item.label"
class="fida-setting-slider-item"
>
<div class="fida-slider-label">{{ translate(item.label) }}</div>
<div class="fida-slider-row flex align-center">
<el-slider
class="setting-popover-slider"
:model-value="item.value"
:show-tooltip="false"
@update:model-value="updateOptionValue(index, $event)"
/>
<span class="fida-slider-value">{{ item.value }}%</span>
</div>
</div>
</div>
</div>
</el-popover>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { SettingOption } from './types'
const props = defineProps<{
options: SettingOption[]
title: string
translate: (key: string) => string
}>()
const emit = defineEmits<{
(e: 'update:options', value: SettingOption[]): void
}>()
const settingPopupVisible = ref(false)
const updateOptionValue = (index: number, value: number | number[]) => {
const nextValue = Array.isArray(value) ? value[0] : value
const nextOptions = props.options.map((item, itemIndex) =>
itemIndex === index ? { ...item, value: nextValue } : item
)
emit('update:options', nextOptions)
}
</script>
<style lang="less" scoped>
.setting-icon {
width: 2.4rem;
height: 2.4rem;
cursor: pointer;
}
</style>
<style lang="less">
.fida-setting-popover {
padding: 0 !important;
border-radius: 0.6rem !important;
background-color: #fff !important;
border: none !important;
width: 25.6rem;
height: 23.9rem;
box-shadow: 0px 11px 20px 0px #0000001a;
}
.fida-setting-popover-header {
font-weight: 400;
font-size: 1.4rem;
color: #000;
margin-bottom: 2rem !important;
}
.fida-setting-popover-content {
padding: 1.6rem 1.4rem 2.2rem !important;
}
.fida-setting-slider-list {
display: flex;
flex-direction: column;
row-gap: 1rem;
}
.fida-setting-slider-item {
.fida-slider-label {
font-weight: 400;
font-size: 1.2rem;
color: #000;
margin-bottom: 1rem;
}
.fida-slider-row {
column-gap: 2.6rem;
.el-slider {
flex: 1;
}
.fida-slider-value {
font-weight: 400;
font-size: 1.4rem;
color: #000;
}
}
}
.setting-popover-slider {
--el-slider-height: 0.4rem;
height: fit-content;
.el-slider__runway {
height: var(--el-slider-height);
background-color: #e8e8e8;
border-radius: 0.2rem;
}
.el-slider__bar {
height: var(--el-slider-height);
background-color: #000;
border-radius: 0.2rem;
}
.el-slider__button-wrapper {
width: fit-content;
height: fit-content;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
}
.el-slider__button {
width: 1rem;
height: 1rem;
background-color: #000;
border-radius: 50%;
border: none;
}
.el-slider__stop {
display: none;
}
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<div class="fida-style-select-wrapper">
<el-select
:model-value="modelValue"
:placeholder="placeholder"
@focus="openStylePopup"
/>
<el-popover
v-model:visible="stylePopupVisible"
placement="top"
:width="342"
:show-arrow="false"
trigger="click"
popper-class="fida-style-select-popover"
>
<template #reference>
<div class="fida-style-select-trigger"></div>
</template>
<div class="fida-style-popover-content flex flex-col">
<div class="fida-style-popover-header">
{{ title }}
</div>
<div class="fida-style-popover-grid">
<div
v-for="item in options"
:key="item.value"
class="fida-style-popover-item flex flex-center"
:class="{ 'is-selected': tempSelectedValue === item.value }"
@click="selectStyle(item.value)"
>
<img :src="getStyleImage(typeValue, item.value)" class="style-bg" />
<span class="fida-option-label flex flex-center">{{ item.label }}</span>
<img
v-show="tempSelectedValue === item.value"
src="@/assets/images/checked.png"
class="checked-item-icon"
/>
</div>
</div>
<div class="fida-style-popover-footer flex flex-center">
<button class="fida-confirm-btn" @click="confirmStyle">
{{ confirmText }}
</button>
</div>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getStyleImage } from '../style'
import type { OptionItem } from './types'
const props = defineProps<{
modelValue: string
typeValue: string
options: OptionItem[]
placeholder: string
title: string
confirmText: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const tempSelectedValue = ref('')
const stylePopupVisible = ref(false)
const openStylePopup = () => {
tempSelectedValue.value = props.modelValue
stylePopupVisible.value = true
}
const selectStyle = (value: string) => {
tempSelectedValue.value = value
}
const confirmStyle = () => {
emit('update:modelValue', tempSelectedValue.value)
stylePopupVisible.value = false
}
</script>
<style lang="less" scoped>
.fida-style-select-wrapper {
position: relative;
width: 13.9rem;
height: 4rem;
.el-select {
width: 13.9rem;
height: 4rem;
:deep(.el-select__wrapper) {
border-radius: 0.8rem;
height: 100%;
box-shadow: none;
border: 0.1rem solid rgba(0, 0, 0, 0.1);
font-weight: 500;
font-size: 1.4rem;
min-height: initial;
.el-select__placeholder {
color: #000;
}
.el-select__icon {
color: #000;
}
}
}
}
.fida-style-select-trigger {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
cursor: pointer;
}
</style>
<style lang="less">
.fida-style-select-popover {
width: 34.2rem !important;
padding: 0 !important;
border-radius: 0.6rem !important;
box-shadow: 0px 5px 20px 0px rgba(0, 0, 0, 0.15) !important;
background-color: #fff !important;
border: none !important;
}
.fida-style-popover-content {
padding: 2rem 2.4rem 2.4rem;
}
.fida-style-popover-header {
font-weight: 500;
font-size: 1.6rem;
color: #000;
margin-bottom: 2rem;
padding: 2rem 2.4rem !important;
}
.fida-style-popover-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
height: 28.5rem;
overflow-y: auto;
}
.fida-style-popover-item {
height: 9.1rem;
width: 9.1rem;
border-radius: 1.4rem;
cursor: pointer;
position: relative;
border: none;
.checked-item-icon {
position: absolute;
bottom: 0;
right: 0;
transform: translate(50%, 50%);
width: 2.4rem;
height: 2.4rem;
}
.style-bg {
width: 100%;
height: 100%;
border-radius: 1.4rem;
}
.fida-option-label {
font-weight: 500;
font-size: 1.2rem;
color: #fff;
text-align: center;
padding: 0.5rem;
position: absolute;
height: 100%;
width: 100%;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 1.4rem;
}
&.is-selected {
border: 0.3rem solid #000;
.fida-option-label {
display: none;
}
}
}
.fida-style-popover-footer {
padding: 2.4rem 0 !important;
margin-top: 2.4rem;
.fida-confirm-btn {
margin: 0 auto;
width: 15.7rem;
height: 3.4rem;
line-height: 3.4rem;
background-color: #ff7a51;
color: #fff;
border: none;
border-radius: 3.8rem;
font-weight: 500;
font-size: 1.4rem;
cursor: pointer;
}
}
</style>

View File

@@ -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 }
]

View File

@@ -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'

View 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
}
}

View File

@@ -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<UploadedImage[]>([])
const quoteList = ref<string[]>([])
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
}
}