chore: 拆分input组件
This commit is contained in:
File diff suppressed because it is too large
Load Diff
99
src/views/home/components/input/AgentOperatePopover.vue
Normal file
99
src/views/home/components/input/AgentOperatePopover.vue
Normal 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>
|
||||
231
src/views/home/components/input/InputEditor.vue
Normal file
231
src/views/home/components/input/InputEditor.vue
Normal 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>
|
||||
89
src/views/home/components/input/InputImagePreviewList.vue
Normal file
89
src/views/home/components/input/InputImagePreviewList.vue
Normal 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>
|
||||
270
src/views/home/components/input/InputToolbar.vue
Normal file
270
src/views/home/components/input/InputToolbar.vue
Normal 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>
|
||||
46
src/views/home/components/input/ReportShortcutButton.vue
Normal file
46
src/views/home/components/input/ReportShortcutButton.vue
Normal 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>
|
||||
160
src/views/home/components/input/SettingPopover.vue
Normal file
160
src/views/home/components/input/SettingPopover.vue
Normal 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>
|
||||
222
src/views/home/components/input/StyleSelect.vue
Normal file
222
src/views/home/components/input/StyleSelect.vue
Normal 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>
|
||||
48
src/views/home/components/input/options.ts
Normal file
48
src/views/home/components/input/options.ts
Normal 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 }
|
||||
]
|
||||
19
src/views/home/components/input/types.ts
Normal file
19
src/views/home/components/input/types.ts
Normal 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'
|
||||
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
|
||||
}
|
||||
}
|
||||
78
src/views/home/components/input/useInputImages.ts
Normal file
78
src/views/home/components/input/useInputImages.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user