Skip to content

Commit 255a0d6

Browse files
committed
feat(input-area): 添加拖拽调整文本区域大小功能
1 parent f3146b3 commit 255a0d6

2 files changed

Lines changed: 124 additions & 20 deletions

File tree

src/components/chat/InputArea.tsx

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
22
import { Button, Tooltip, App, theme, Dropdown, Tag, Popover, Checkbox, Badge } from 'antd';
33
import type { MenuProps } from 'antd';
4-
import { Paperclip, Trash2, Mic, Eraser, Scissors, Globe, Brain, BrainCog, Plug, SlidersHorizontal, ArrowUp, Square, Check, Zap, ZapOff, Gauge, Shrink, Upload, LayoutGrid, X, BookOpen } from 'lucide-react';
4+
import { Paperclip, Trash2, Mic, Eraser, Scissors, Globe, Brain, BrainCog, Plug, SlidersHorizontal, ArrowUp, Square, Check, Zap, ZapOff, Gauge, Shrink, Upload, LayoutGrid, X, BookOpen, GripHorizontal } from 'lucide-react';
55
import { useTranslation } from 'react-i18next';
66
import { useConversationStore, useProviderStore, useSettingsStore, useSearchStore, useMcpStore, useMemoryStore, useKnowledgeStore } from '@/stores';
77
import { useUIStore } from '@/stores/uiStore';
@@ -58,6 +58,15 @@ export function InputArea() {
5858
useConversationStore.getState().activeConversationId ?? null
5959
);
6060

61+
// Drag-to-resize state: userMinHeight controls the minimum visible height of the textarea
62+
const INITIAL_MIN_HEIGHT = 44;
63+
const ABSOLUTE_MAX_HEIGHT = 600;
64+
const [userMinHeight, setUserMinHeight] = useState(INITIAL_MIN_HEIGHT);
65+
const userMinHeightRef = useRef(userMinHeight);
66+
userMinHeightRef.current = userMinHeight;
67+
const dragStateRef = useRef<{ startY: number; startH: number } | null>(null);
68+
const containerRef = useRef<HTMLDivElement>(null);
69+
6170
// Multi-model companion state
6271
const [companionModels, setCompanionModels] = useState<Array<{ providerId: string; modelId: string }>>([]);
6372
const [multiModelOpen, setMultiModelOpen] = useState(false);
@@ -583,7 +592,8 @@ export function InputArea() {
583592
const textarea = textareaRef.current;
584593
if (textarea) {
585594
textarea.style.height = 'auto';
586-
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
595+
const desired = Math.max(textarea.scrollHeight, userMinHeightRef.current);
596+
textarea.style.height = Math.min(desired, ABSOLUTE_MAX_HEIGHT) + 'px';
587597
}
588598
});
589599
}
@@ -601,7 +611,8 @@ export function InputArea() {
601611
if (!textarea) return;
602612
textarea.focus();
603613
textarea.style.height = 'auto';
604-
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
614+
const desired = Math.max(textarea.scrollHeight, userMinHeightRef.current);
615+
textarea.style.height = Math.min(desired, ABSOLUTE_MAX_HEIGHT) + 'px';
605616
});
606617
}, [messages, streaming]);
607618

@@ -712,12 +723,47 @@ export function InputArea() {
712723
[handleSend],
713724
);
714725

715-
// Auto-resize textarea
726+
// Auto-resize textarea: height = max(userMinHeight, contentHeight), capped at ABSOLUTE_MAX
727+
const autoResizeTextarea = useCallback((el: HTMLTextAreaElement) => {
728+
el.style.height = 'auto';
729+
const desired = Math.max(el.scrollHeight, userMinHeightRef.current);
730+
el.style.height = Math.min(desired, ABSOLUTE_MAX_HEIGHT) + 'px';
731+
}, []);
732+
716733
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
717734
setValue(e.target.value);
718-
const el = e.target;
719-
el.style.height = 'auto';
720-
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
735+
autoResizeTextarea(e.target);
736+
}, [autoResizeTextarea]);
737+
738+
// Drag-to-resize: changes userMinHeight so the textarea grows even with short content
739+
const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
740+
e.preventDefault();
741+
const textarea = textareaRef.current;
742+
const startHeight = textarea ? textarea.offsetHeight : userMinHeightRef.current;
743+
dragStateRef.current = { startY: e.clientY, startH: startHeight };
744+
const onMouseMove = (ev: MouseEvent) => {
745+
if (!dragStateRef.current) return;
746+
const delta = dragStateRef.current.startY - ev.clientY;
747+
const newH = Math.max(INITIAL_MIN_HEIGHT, Math.min(ABSOLUTE_MAX_HEIGHT, dragStateRef.current.startH + delta));
748+
setUserMinHeight(newH);
749+
userMinHeightRef.current = newH;
750+
if (textarea) {
751+
textarea.style.height = 'auto';
752+
const desired = Math.max(textarea.scrollHeight, newH);
753+
textarea.style.height = Math.min(desired, ABSOLUTE_MAX_HEIGHT) + 'px';
754+
}
755+
};
756+
const onMouseUp = () => {
757+
dragStateRef.current = null;
758+
document.removeEventListener('mousemove', onMouseMove);
759+
document.removeEventListener('mouseup', onMouseUp);
760+
document.body.style.cursor = '';
761+
document.body.style.userSelect = '';
762+
};
763+
document.addEventListener('mousemove', onMouseMove);
764+
document.addEventListener('mouseup', onMouseUp);
765+
document.body.style.cursor = 'ns-resize';
766+
document.body.style.userSelect = 'none';
721767
}, []);
722768

723769
// Listen for Escape to close voice overlay
@@ -778,7 +824,8 @@ export function InputArea() {
778824
if (!textarea) return;
779825
textarea.focus();
780826
textarea.style.height = 'auto';
781-
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
827+
const desired = Math.max(textarea.scrollHeight, userMinHeightRef.current);
828+
textarea.style.height = Math.min(desired, ABSOLUTE_MAX_HEIGHT) + 'px';
782829
});
783830
};
784831
window.addEventListener('aqbot:fill-input', onFillInput);
@@ -821,13 +868,28 @@ export function InputArea() {
821868

822869
{/* Main input container */}
823870
<div
871+
ref={containerRef}
824872
style={{
825873
border: '1px solid var(--border-color)',
826874
borderRadius: 16,
827875
backgroundColor: token.colorBgContainer,
828876
overflow: 'hidden',
829877
}}
830878
>
879+
{/* Drag-to-resize handle */}
880+
<div
881+
onMouseDown={handleResizeMouseDown}
882+
style={{
883+
height: 10,
884+
cursor: 'ns-resize',
885+
display: 'flex',
886+
alignItems: 'center',
887+
justifyContent: 'center',
888+
flexShrink: 0,
889+
}}
890+
>
891+
<GripHorizontal size={14} style={{ color: token.colorTextQuaternary, opacity: 0.5 }} />
892+
</div>
831893
{/* Companion model tags */}
832894
{companionModels.length > 0 && (
833895
<div className="flex flex-wrap gap-1.5 px-3 pt-3 pb-1">
@@ -893,14 +955,15 @@ export function InputArea() {
893955
border: 'none',
894956
outline: 'none',
895957
resize: 'none',
896-
padding: '14px 16px 8px',
958+
padding: '4px 16px 8px',
897959
fontSize: token.fontSize,
898960
lineHeight: 1.6,
899961
backgroundColor: 'transparent',
900962
color: token.colorText,
901963
fontFamily: 'inherit',
902-
minHeight: 44,
903-
maxHeight: 200,
964+
minHeight: userMinHeight,
965+
maxHeight: ABSOLUTE_MAX_HEIGHT,
966+
overflowY: 'auto',
904967
}}
905968
/>
906969

src/components/settings/SettingsSidebar.tsx

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Menu, theme } from 'antd';
2-
import { Cloud, Settings, Palette, Globe, Zap, Database, Info, Search, Plug, CloudUpload, Bot, HardDrive, MessageSquare } from 'lucide-react';
2+
import { Cloud, Settings, Palette, Globe, Zap, Database, Info, Search, Plug, CloudUpload, Bot, HardDrive, MessageSquare, ArrowLeft } from 'lucide-react';
33
import { useTranslation } from 'react-i18next';
44
import { useUIStore } from '@/stores';
55
import type { SettingsSection } from '@/types';
@@ -41,6 +41,7 @@ export function SettingsSidebar() {
4141
const { token } = theme.useToken();
4242
const settingsSection = useUIStore((s) => s.settingsSection);
4343
const setSettingsSection = useUIStore((s) => s.setSettingsSection);
44+
const exitSettings = useUIStore((s) => s.exitSettings);
4445

4546
const items = SECTION_KEYS.map((key) => ({
4647
key,
@@ -49,14 +50,54 @@ export function SettingsSidebar() {
4950
}));
5051

5152
return (
52-
<div className="h-full pt-1" data-os-scrollbar style={{ backgroundColor: token.colorBgContainer, overflowY: 'auto' }}>
53-
<Menu
54-
mode="inline"
55-
selectedKeys={[settingsSection]}
56-
items={items}
57-
style={{ borderInlineEnd: 'none' }}
58-
onClick={({ key }) => setSettingsSection(key as SettingsSection)}
59-
/>
53+
<div className="h-full flex flex-col" data-os-scrollbar style={{ backgroundColor: token.colorBgContainer, overflowY: 'auto' }}>
54+
{/* Back button */}
55+
<div
56+
className="flex items-center gap-2 cursor-pointer"
57+
style={{
58+
color: token.colorTextSecondary,
59+
borderBottom: `1px solid ${token.colorBorderSecondary}`,
60+
flexShrink: 0,
61+
paddingLeft: 26,
62+
paddingRight: 16,
63+
paddingTop: 12,
64+
paddingBottom: 12,
65+
}}
66+
onClick={exitSettings}
67+
onMouseEnter={(e) => {
68+
e.currentTarget.style.color = token.colorText;
69+
e.currentTarget.style.backgroundColor = token.colorFillSecondary;
70+
}}
71+
onMouseLeave={(e) => {
72+
e.currentTarget.style.color = token.colorTextSecondary;
73+
e.currentTarget.style.backgroundColor = 'transparent';
74+
}}
75+
>
76+
<ArrowLeft size={16} />
77+
<span style={{ fontSize: 14 }}>{t('common.back', '返回')}</span>
78+
<span
79+
style={{
80+
fontSize: 11,
81+
color: token.colorTextQuaternary,
82+
border: `1px solid ${token.colorBorderSecondary}`,
83+
borderRadius: 4,
84+
padding: '1px 6px',
85+
marginLeft: 4,
86+
lineHeight: '16px',
87+
}}
88+
>
89+
Esc
90+
</span>
91+
</div>
92+
<div className="flex-1 pt-1" style={{ overflowY: 'auto' }}>
93+
<Menu
94+
mode="inline"
95+
selectedKeys={[settingsSection]}
96+
items={items}
97+
style={{ borderInlineEnd: 'none' }}
98+
onClick={({ key }) => setSettingsSection(key as SettingsSection)}
99+
/>
100+
</div>
60101
</div>
61102
);
62103
}

0 commit comments

Comments
 (0)