feat(chat): add custom drag-resize handle on input top border (#725)
* feat(chat): add custom drag-resize handle on input top border * fix(chat): skip auto-resize when user has manually set height via drag handle
This commit is contained in:
@@ -20,6 +20,37 @@ const isDragging = ref(false)
|
|||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
const isComposing = ref(false)
|
const isComposing = ref(false)
|
||||||
|
|
||||||
|
// 自定义高度拖拽
|
||||||
|
const textareaHeight = ref<number | null>(null) // null = auto
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const el = textareaRef.value
|
||||||
|
if (!el) return
|
||||||
|
// 如果当前是 auto,用实际 clientHeight 作为起始值
|
||||||
|
const startHeight = el.clientHeight
|
||||||
|
const startY = e.clientY
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
const deltaY = e.clientY - startY
|
||||||
|
// 往上拖 (deltaY < 0) → 高度增加
|
||||||
|
const newHeight = startHeight - deltaY
|
||||||
|
textareaHeight.value = Math.max(20, Math.min(400, Math.round(newHeight)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.cursor = 'row-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
// 自动播放语音开关
|
// 自动播放语音开关
|
||||||
const autoPlaySpeech = ref(false)
|
const autoPlaySpeech = ref(false)
|
||||||
|
|
||||||
@@ -229,6 +260,8 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
function handleInput(e: Event) {
|
||||||
|
// 用户手动拖拽自定义高度时,不覆盖
|
||||||
|
if (textareaHeight.value !== null) return
|
||||||
const el = e.target as HTMLTextAreaElement
|
const el = e.target as HTMLTextAreaElement
|
||||||
el.style.height = 'auto'
|
el.style.height = 'auto'
|
||||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
||||||
@@ -349,10 +382,12 @@ function isImage(type: string): boolean {
|
|||||||
class="file-input-hidden"
|
class="file-input-hidden"
|
||||||
@change="handleFileChange"
|
@change="handleFileChange"
|
||||||
/>
|
/>
|
||||||
|
<div class="resize-handle" @mousedown="startResize"></div>
|
||||||
<textarea
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
class="input-textarea"
|
class="input-textarea"
|
||||||
|
:style="textareaHeight ? { height: textareaHeight + 'px' } : {}"
|
||||||
:placeholder="t('chat.inputPlaceholder')"
|
:placeholder="t('chat.inputPlaceholder')"
|
||||||
rows="1"
|
rows="1"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
@@ -594,6 +629,7 @@ function isImage(type: string): boolean {
|
|||||||
border: 1px solid $border-color;
|
border: 1px solid $border-color;
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
position: relative;
|
||||||
transition: border-color $transition-fast, background-color $transition-fast;
|
transition: border-color $transition-fast, background-color $transition-fast;
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
@@ -605,6 +641,21 @@ function isImage(type: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 8px;
|
||||||
|
cursor: row-resize;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($accent-primary, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.input-textarea {
|
.input-textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -615,7 +666,7 @@ function isImage(type: string): boolean {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
resize: none;
|
resize: none;
|
||||||
max-height: 100px;
|
max-height: 400px;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,35 @@ const textareaRef = ref<HTMLTextAreaElement>()
|
|||||||
const dropdownRef = ref<HTMLDivElement>()
|
const dropdownRef = ref<HTMLDivElement>()
|
||||||
const isComposing = ref(false)
|
const isComposing = ref(false)
|
||||||
|
|
||||||
|
// 自定义高度拖拽
|
||||||
|
const textareaHeight = ref<number | null>(null)
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const el = textareaRef.value
|
||||||
|
if (!el) return
|
||||||
|
const startHeight = el.clientHeight
|
||||||
|
const startY = e.clientY
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
const deltaY = e.clientY - startY
|
||||||
|
const newHeight = startHeight - deltaY
|
||||||
|
textareaHeight.value = Math.max(20, Math.min(400, Math.round(newHeight)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.cursor = 'row-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Mention State ───────────────────────────────────────
|
// ─── Mention State ───────────────────────────────────────
|
||||||
|
|
||||||
const mentionActive = ref(false)
|
const mentionActive = ref(false)
|
||||||
@@ -125,8 +154,10 @@ function selectMention(name: string) {
|
|||||||
const newPos = before.length + name.length + 2
|
const newPos = before.length + name.length + 2
|
||||||
el.setSelectionRange(newPos, newPos)
|
el.setSelectionRange(newPos, newPos)
|
||||||
el.focus()
|
el.focus()
|
||||||
el.style.height = 'auto'
|
if (textareaHeight.value === null) {
|
||||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -173,15 +204,12 @@ function handleSend() {
|
|||||||
emit('send', content)
|
emit('send', content)
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
mentionActive.value = false
|
mentionActive.value = false
|
||||||
|
// 发送后重置到自定义高度(不清除拖拽状态)
|
||||||
nextTick(() => {
|
|
||||||
if (textareaRef.value) {
|
|
||||||
textareaRef.value.style.height = 'auto'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
function handleInput(e: Event) {
|
||||||
|
// 用户手动拖拽自定义高度时,不覆盖
|
||||||
|
if (textareaHeight.value !== null) return
|
||||||
store.emitTyping()
|
store.emitTyping()
|
||||||
const el = e.target as HTMLTextAreaElement
|
const el = e.target as HTMLTextAreaElement
|
||||||
el.style.height = 'auto'
|
el.style.height = 'auto'
|
||||||
@@ -233,10 +261,12 @@ function handleCompositionEnd() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-input-area">
|
<div class="chat-input-area">
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
|
<div class="resize-handle" @mousedown="startResize"></div>
|
||||||
<textarea
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
class="input-textarea"
|
class="input-textarea"
|
||||||
|
:style="textareaHeight ? { height: textareaHeight + 'px' } : {}"
|
||||||
:placeholder="t('groupChat.inputPlaceholder')"
|
:placeholder="t('groupChat.inputPlaceholder')"
|
||||||
rows="1"
|
rows="1"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
@@ -326,6 +356,7 @@ function handleCompositionEnd() {
|
|||||||
border: 1px solid $border-color;
|
border: 1px solid $border-color;
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
position: relative;
|
||||||
transition: border-color $transition-fast, background-color $transition-fast;
|
transition: border-color $transition-fast, background-color $transition-fast;
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
@@ -337,6 +368,21 @@ function handleCompositionEnd() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 8px;
|
||||||
|
cursor: row-resize;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($accent-primary, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.input-textarea {
|
.input-textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -347,7 +393,7 @@ function handleCompositionEnd() {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
resize: none;
|
resize: none;
|
||||||
max-height: 100px;
|
max-height: 400px;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user