mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-30 20:49:21 +08:00
fix(search): adapt input shape and button position to content line count (#17529)
This commit is contained in:
@@ -1,26 +1,67 @@
|
||||
import { useLayoutEffect, type RefObject } from 'react';
|
||||
import { useLayoutEffect, useState, type RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Auto-resize a textarea to fit its content, clamped to a maximum height.
|
||||
*
|
||||
* useLayoutEffect runs synchronously after DOM mutation but before the
|
||||
* browser paints, so the height adjustment never produces a visible flicker.
|
||||
*
|
||||
* Measurement also re-runs when the element's content-box width changes
|
||||
* (e.g. responsive layout or font-metric changes): soft-wrapping can add or
|
||||
* remove lines without `value` changing, which would otherwise leave
|
||||
* `isMultiLine` stale and misposition controls. A ResizeObserver watches
|
||||
* width only; height-only changes (which we cause ourselves when setting
|
||||
* `el.style.height`) are ignored to avoid feedback loops.
|
||||
*
|
||||
* Returns `isMultiLine` - true when the rendered content spans more than one
|
||||
* line (including soft-wrapped lines), derived from scrollHeight vs. the
|
||||
* computed single-line height.
|
||||
*/
|
||||
export function useAutoResizeTextarea(
|
||||
ref: RefObject<HTMLTextAreaElement | null>,
|
||||
value: string,
|
||||
maxHeight = 160,
|
||||
) {
|
||||
const [isMultiLine, setIsMultiLine] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
// scrollHeight excludes borders while height is border-box, so the
|
||||
// content box ends up a couple of pixels short and overflow-y-auto
|
||||
// would show a scrollbar even for a single line. Hide it until the
|
||||
// content actually exceeds the max height.
|
||||
const overflowing = el.scrollHeight > maxHeight;
|
||||
el.style.overflowY = overflowing ? 'auto' : 'hidden';
|
||||
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
|
||||
|
||||
const measure = () => {
|
||||
el.style.height = 'auto';
|
||||
// scrollHeight excludes borders while height is border-box, so the
|
||||
// content box ends up a couple of pixels short and overflow-y-auto
|
||||
// would show a scrollbar even for a single line. Hide it until the
|
||||
// content actually exceeds the max height.
|
||||
const scrollHeight = el.scrollHeight;
|
||||
const overflowing = scrollHeight > maxHeight;
|
||||
el.style.overflowY = overflowing ? 'auto' : 'hidden';
|
||||
el.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const lineHeight =
|
||||
parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
|
||||
const verticalPadding =
|
||||
parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
|
||||
setIsMultiLine(scrollHeight > verticalPadding + lineHeight + 1);
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
let lastWidth: number | undefined;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width;
|
||||
if (width === lastWidth) continue;
|
||||
lastWidth = width;
|
||||
measure();
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, [ref, value, maxHeight]);
|
||||
|
||||
return isMultiLine;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Spotlight from '@/components/spotlight';
|
||||
import message from '@/components/ui/message';
|
||||
import { IUserInfo } from '@/interfaces/database/user-setting';
|
||||
import { useAutoResizeTextarea } from '@/hooks/use-auto-resize-textarea';
|
||||
import { IUserInfo } from '@/interfaces/database/user-setting';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Dispatch, SetStateAction, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -29,7 +30,7 @@ export default function SearchHome({
|
||||
const { t } = useTranslation();
|
||||
const searchInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useAutoResizeTextarea(searchInputRef, searchText);
|
||||
const isMultiLine = useAutoResizeTextarea(searchInputRef, searchText);
|
||||
|
||||
return (
|
||||
<section className="relative w-full flex transition-all justify-center items-center mt-[15vh]">
|
||||
@@ -56,7 +57,10 @@ export default function SearchHome({
|
||||
ref={searchInputRef}
|
||||
rows={1}
|
||||
placeholder={t('search.searchGreeting')}
|
||||
className="w-full rounded-3xl py-4 px-4 pr-14 text-text-primary text-lg bg-bg-base delay-700 border border-border-button resize-none scrollbar-thin outline-none focus-visible:ring-1 focus-visible:ring-text-primary/50"
|
||||
className={cn(
|
||||
'w-full py-4 px-4 pr-14 text-text-primary text-lg bg-bg-base border border-border-button resize-none scrollbar-thin outline-none focus-visible:ring-1 focus-visible:ring-text-primary/50',
|
||||
isMultiLine ? 'rounded-3xl' : 'rounded-full',
|
||||
)}
|
||||
value={searchText}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
@@ -82,7 +86,10 @@ export default function SearchHome({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-3 right-3 flex size-9 items-center justify-center rounded-full bg-text-primary text-bg-base shadow transition-opacity hover:opacity-90"
|
||||
className={cn(
|
||||
'absolute right-3 flex size-9 items-center justify-center rounded-full bg-text-primary text-bg-base shadow transition-opacity hover:opacity-90',
|
||||
isMultiLine ? 'bottom-3' : 'top-1/2 -translate-y-1/2',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (canSearch === false) {
|
||||
message.warning(t('search.chooseDataset'));
|
||||
|
||||
@@ -79,8 +79,7 @@ export default function SearchingView({
|
||||
setSearchText(searchStr);
|
||||
}, [searchStr, setSearchText]);
|
||||
|
||||
useAutoResizeTextarea(searchInputRef, searchText);
|
||||
|
||||
const isMultiLine = useAutoResizeTextarea(searchInputRef, searchText);
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -111,7 +110,8 @@ export default function SearchingView({
|
||||
rows={1}
|
||||
placeholder={t('search.searchGreeting')}
|
||||
className={cn(
|
||||
'w-full rounded-3xl py-4 pl-4 !pr-[8rem] text-primary text-lg bg-bg-base border border-border-button resize-none scrollbar-thin outline-none focus-visible:ring-1 focus-visible:ring-text-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'w-full rounded-full py-4 pl-4 !pr-[8rem] text-primary text-lg bg-bg-base border border-border-button resize-none scrollbar-thin outline-none focus-visible:ring-1 focus-visible:ring-text-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isMultiLine ? 'rounded-3xl' : 'rounded-full',
|
||||
)}
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
@@ -129,7 +129,12 @@ export default function SearchingView({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-3 flex items-center gap-3',
|
||||
isMultiLine ? 'bottom-3' : 'top-1/2 -translate-y-1/2',
|
||||
)}
|
||||
>
|
||||
{searchText && (
|
||||
<X
|
||||
className="text-text-secondary cursor-pointer opacity-80 hover:opacity-100"
|
||||
|
||||
Reference in New Issue
Block a user