mirror of
https://github.com/infinilabs/coco-app.git
synced 2026-08-29 10:09:30 +02:00
fix: fix multiline input issue (#808)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user