fix: fix multiline input issue (#808)

This commit is contained in:
ayangweb
2025-07-24 10:58:57 +08:00
committed by GitHub
parent e8f9a4e627
commit abe2aecedf
3 changed files with 134 additions and 160 deletions

View File

@@ -36,6 +36,7 @@ Information about release notes of Coco Server is provided here.
- fix: fix selection issue after renaming #800
- fix: fix shortcut issue in windows context menu #804
- fix: panic caused by "state() called before manage()" #806
- fix: fix multiline input issue #808
### ✈️ Improvements
@@ -59,7 +60,6 @@ Information about release notes of Coco Server is provided here.
- refactor: clean up unsupported characters from query string in Win Search #802
- chore: display backtrace in panic log #805
## 0.6.0 (2025-06-29)
### ❌ Breaking changes

View File

@@ -1,17 +1,16 @@
import { useBoolean, useDebounceFn } from "ahooks";
import { useBoolean } from "ahooks";
import {
useRef,
useImperativeHandle,
forwardRef,
KeyboardEvent,
useEffect,
useCallback,
ChangeEvent,
useRef,
useEffect,
} from "react";
import { useTranslation } from "react-i18next";
const LINE_HEIGHT = 24; // 1.5rem
const MAX_FIRST_LINE_WIDTH = 470; // Width in pixels for first line
const MAX_HEIGHT = 240; // 15rem
const MAX_HEIGHT = 240;
interface AutoResizeTextareaProps {
isChatMode: boolean;
@@ -21,6 +20,7 @@ interface AutoResizeTextareaProps {
chatPlaceholder?: string;
lineCount?: number;
onLineCountChange?: (lineCount: number) => void;
firstLineMaxWidth: number;
}
// Forward ref to allow parent to interact with this component
@@ -35,87 +35,15 @@ const AutoResizeTextarea = forwardRef<
setInput,
handleKeyDown,
chatPlaceholder,
lineCount = 1,
onLineCountChange,
firstLineMaxWidth,
},
ref
) => {
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isComposition, { setTrue, setFalse }] = useBoolean();
// Memoize resize logic
const { run: debouncedResize } = useDebounceFn(
() => {
const textarea = textareaRef.current;
if (!textarea) return;
if (typeof window === "undefined" || typeof document === "undefined")
return;
// Reset height to auto to get the correct scrollHeight
textarea.style.height = "auto";
// Create a hidden span to measure first line width
const span = document.createElement("span");
span.style.visibility = "hidden";
span.style.position = "absolute";
span.style.whiteSpace = "pre";
span.style.font = window.getComputedStyle(textarea).font;
// Get first line content
const content = textarea.value;
const firstLineEnd =
content.indexOf("\n") === -1 ? content.length : content.indexOf("\n");
span.textContent = content.slice(0, firstLineEnd);
document.body.appendChild(span);
// Calculate lines based on first line width
const firstLineWidth = span.offsetWidth;
document.body.removeChild(span);
// Start with 1 line
let lines = 1;
// Add a line if first line exceeds max width
if (firstLineWidth > MAX_FIRST_LINE_WIDTH) {
lines += 1;
}
// Add lines based on scrollHeight for remaining content
const scrollHeight = textarea.scrollHeight;
const remainingLines = Math.floor(
(scrollHeight - LINE_HEIGHT) / LINE_HEIGHT
);
lines += Math.max(0, remainingLines);
// Calculate final height
const newHeight = Math.min(lines * LINE_HEIGHT, MAX_HEIGHT);
// Only update if height actually changed
if (textarea.style.height !== `${newHeight}px`) {
textarea.style.height = `${newHeight}px`;
onLineCountChange?.(lines);
}
},
{ wait: 100 }
);
// Handle input changes and initial setup
useEffect(() => {
if (textareaRef.current) {
debouncedResize();
}
}, [input, debouncedResize]);
useEffect(() => {
if (textareaRef.current) {
requestAnimationFrame(() => {
// Set cursor position to end
const length = textareaRef.current?.value.length || 0;
textareaRef.current?.setSelectionRange(length, length);
});
}
}, [lineCount]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const calcRef = useRef<HTMLDivElement>(null);
// Expose methods to the parent via ref
useImperativeHandle(ref, () => ({
@@ -135,40 +63,67 @@ const AutoResizeTextarea = forwardRef<
handleKeyDown?.(event);
};
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea || !calcRef.current) return;
if (!calcRef.current) return;
textarea.style.height = "auto";
const computedStyle = getComputedStyle(textarea);
const lineHeight = parseInt(computedStyle.lineHeight);
let height = lineHeight;
let minHeight = lineHeight;
if (calcRef.current?.offsetWidth >= firstLineMaxWidth - 32) {
minHeight = lineHeight * 2;
height = Math.min(
Math.max(minHeight, textarea.scrollHeight),
MAX_HEIGHT
);
}
textarea.style.height = `${height}px`;
textarea.style.minHeight = `${minHeight}px`;
onLineCountChange?.(height / lineHeight);
}, [input, firstLineMaxWidth]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
(event: ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.currentTarget.value);
},
[setInput]
);
return (
<textarea
ref={textareaRef}
id={isChatMode ? "chat-textarea" : "search-textarea"}
autoFocus
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar"
placeholder={chatPlaceholder || t("search.textarea.placeholder")}
aria-label={t("search.textarea.ariaLabel")}
value={input}
onChange={handleChange}
onKeyDown={handleKeyPress}
onCompositionStart={setTrue}
onCompositionEnd={() => {
setTimeout(setFalse, 0);
}}
rows={1}
style={{
resize: "none", // Prevent manual resize
overflow: "auto",
minHeight: "1.5rem",
maxHeight: "13.5rem", // Limit height to 9 rows (9 * 1.5 line-height)
lineHeight: "1.5rem", // Line height to match row height
}}
/>
<>
<textarea
ref={textareaRef}
id={isChatMode ? "chat-textarea" : "search-textarea"}
autoFocus
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar resize-none overflow-y-auto"
placeholder={chatPlaceholder || t("search.textarea.placeholder")}
aria-label={t("search.textarea.ariaLabel")}
value={input}
onChange={handleChange}
onKeyDown={handleKeyPress}
onCompositionStart={setTrue}
onCompositionEnd={() => {
setTimeout(setFalse, 0);
}}
rows={1}
/>
<div ref={calcRef} className="absolute whitespace-nowrap -z-10">
{input}
</div>
</>
);
}
);

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useKeyPress } from "ahooks";
import { useKeyPress, useSize } from "ahooks";
import AutoResizeTextarea from "./AutoResizeTextarea";
import { useChatStore } from "@/stores/chatStore";
@@ -19,6 +19,7 @@ import { useExtensionsStore } from "@/stores/extensionsStore";
import AudioRecording from "../AudioRecording";
import { isDefaultServer } from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import clsx from "clsx";
interface ChatInputProps {
onSend: (message: string) => void;
@@ -199,29 +200,39 @@ export default function ChatInput({
const { currentService } = useConnectStore();
const [visibleAudioInput, setVisibleAudioInput] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const containerSize = useSize(containerRef);
const searchIconRef = useRef<HTMLDivElement>(null);
const searchIconSize = useSize(searchIconRef);
const extraIconRef = useRef<HTMLDivElement>(null);
const extraIconSize = useSize(extraIconRef);
useEffect(() => {
setVisibleAudioInput(isDefaultServer());
}, [currentService]);
const renderSearchIcon = () => (
<SearchIcons
lineCount={lineCount}
isChatMode={isChatMode}
assistant={askAIRef.current}
/>
<div ref={searchIconRef} className="w-fit">
<SearchIcons
lineCount={lineCount}
isChatMode={isChatMode}
assistant={askAIRef.current}
/>
</div>
);
const renderExtraIcon = () => (
<div className="flex items-center gap-2">
<ChatIcons
lineCount={lineCount}
isChatMode={isChatMode}
curChatEnd={curChatEnd}
inputValue={inputValue}
onSend={onSend}
disabledChange={disabledChange}
/>
<div ref={extraIconRef} className="flex items-center gap-2 w-fit">
{isChatMode && (
<ChatIcons
lineCount={lineCount}
isChatMode={isChatMode}
curChatEnd={curChatEnd}
inputValue={inputValue}
onSend={onSend}
disabledChange={disabledChange}
/>
)}
{!isChatMode &&
(sourceData || visibleExtensionStore || selectedExtension) && (
@@ -291,55 +302,63 @@ export default function ChatInput({
</div>
);
const renderTextarea = () => (
<VisibleKey
shortcut={returnToInput}
rootClassName="flex-1 flex items-center justify-center"
shortcutClassName="!left-0 !translate-x-0"
>
<AutoResizeTextarea
ref={textareaRef}
isChatMode={isChatMode}
input={inputValue}
setInput={handleInputChange}
handleKeyDown={handleKeyDownAutoResizeTextarea}
chatPlaceholder={
isChatMode
? assistantConfig.placeholder || chatPlaceholder
: goAskAi
? assistantDetail?._source?.chat_settings?.placeholder
: searchPlaceholder || t("search.input.searchPlaceholder")
}
lineCount={lineCount}
onLineCountChange={setLineCount}
/>
</VisibleKey>
);
const renderTextarea = () => {
return (
<VisibleKey
shortcut={returnToInput}
rootClassName="flex-1 flex items-center justify-center"
shortcutClassName="!left-0 !translate-x-0"
>
<AutoResizeTextarea
ref={textareaRef}
isChatMode={isChatMode}
input={inputValue}
setInput={handleInputChange}
handleKeyDown={handleKeyDownAutoResizeTextarea}
chatPlaceholder={
isChatMode
? assistantConfig.placeholder || chatPlaceholder
: goAskAi
? assistantDetail?._source?.chat_settings?.placeholder
: searchPlaceholder || t("search.input.searchPlaceholder")
}
lineCount={lineCount}
onLineCountChange={setLineCount}
firstLineMaxWidth={
(containerSize?.width ?? 0) -
(searchIconSize?.width ?? 0) -
(extraIconSize?.width ?? 0)
}
/>
</VisibleKey>
);
};
return (
<div className={`w-full relative`}>
<div
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded-md transition-all relative overflow-hidden`}
>
{lineCount === 1 ? (
<div className="relative flex items-center gap-2 w-full">
{renderSearchIcon()}
<div
ref={containerRef}
className={clsx("relative w-full", {
"flex items-center gap-2": lineCount === 1,
})}
>
{lineCount === 1 && renderSearchIcon()}
{renderTextarea()}
{renderTextarea()}
{renderExtraIcon()}
</div>
) : (
<div className="relative w-full">
{renderTextarea()}
{lineCount === 1 && renderExtraIcon()}
{lineCount > 1 && (
<div className="flex items-center mt-2">
<div className="flex-1">{renderSearchIcon()}</div>
<div className="self-end">{renderExtraIcon()}</div>
</div>
</div>
)}
)}
</div>
</div>
<InputControls