From f8e01d558e2c3b1143e91fc03153831cfe58e1a2 Mon Sep 17 00:00:00 2001 From: chanx <1243304602@qq.com> Date: Wed, 29 Jul 2026 17:15:19 +0800 Subject: [PATCH] fix(search): adapt input shape and button position to content line count (#17529) --- web/src/hooks/use-auto-resize-textarea.ts | 59 +++++++++++++++++++---- web/src/pages/next-search/search-home.tsx | 15 ++++-- web/src/pages/next-search/search-view.tsx | 13 +++-- 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/web/src/hooks/use-auto-resize-textarea.ts b/web/src/hooks/use-auto-resize-textarea.ts index 74e57720f4..d11a236606 100644 --- a/web/src/hooks/use-auto-resize-textarea.ts +++ b/web/src/hooks/use-auto-resize-textarea.ts @@ -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, 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; } diff --git a/web/src/pages/next-search/search-home.tsx b/web/src/pages/next-search/search-home.tsx index 0cc481b243..0e141d17f7 100644 --- a/web/src/pages/next-search/search-home.tsx +++ b/web/src/pages/next-search/search-home.tsx @@ -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(null); - useAutoResizeTextarea(searchInputRef, searchText); + const isMultiLine = useAutoResizeTextarea(searchInputRef, searchText); return (
@@ -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({ />