diff --git a/packages/app/src/composer/input/height-mirror.d.ts b/packages/app/src/composer/input/height-mirror.d.ts deleted file mode 100644 index 17ba034593..0000000000 --- a/packages/app/src/composer/input/height-mirror.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./height-mirror.native"; diff --git a/packages/app/src/composer/input/height-mirror.native.ts b/packages/app/src/composer/input/height-mirror.native.ts deleted file mode 100644 index f8c12873db..0000000000 --- a/packages/app/src/composer/input/height-mirror.native.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { RefObject } from "react"; - -interface Args { - value: string; - textareaRef: RefObject; - minHeight: number; - maxHeight: number; - onHeight: (height: number) => void; -} - -export function useComposerHeightMirror(_args: Args): (_value: string) => void { - // No-op on native: onContentSizeChange drives height natively. - return () => undefined; -} diff --git a/packages/app/src/composer/input/height-mirror.web.ts b/packages/app/src/composer/input/height-mirror.web.ts deleted file mode 100644 index 2be9d9642f..0000000000 --- a/packages/app/src/composer/input/height-mirror.web.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; -import type { RefObject } from "react"; - -interface Args { - value: string; - textareaRef: RefObject; - minHeight: number; - maxHeight: number; - onHeight: (height: number) => void; -} - -const COPIED_STYLES = [ - "fontFamily", - "fontSize", - "fontWeight", - "fontStyle", - "fontVariant", - "lineHeight", - "letterSpacing", - "wordSpacing", - "textTransform", - "textIndent", - "whiteSpace", - "wordWrap", - "overflowWrap", - "wordBreak", - "tabSize", - "paddingTop", - "paddingRight", - "paddingBottom", - "paddingLeft", -] as const; - -export function useComposerHeightMirror({ - value, - textareaRef, - minHeight, - maxHeight, - onHeight, -}: Args): (value: string) => void { - const paramsRef = useRef({ value, minHeight, maxHeight, onHeight }); - paramsRef.current = { value, minHeight, maxHeight, onHeight }; - - const mirrorRef = useRef(null); - - useEffect(() => { - if (typeof document === "undefined") return; - const mirror = document.createElement("textarea"); - mirror.setAttribute("aria-hidden", "true"); - mirror.setAttribute("tabindex", "-1"); - mirror.readOnly = true; - mirror.rows = 1; - const style = mirror.style; - style.position = "absolute"; - style.top = "0"; - style.left = "0"; - style.visibility = "hidden"; - style.pointerEvents = "none"; - style.overflow = "hidden"; - style.border = "0"; - style.margin = "0"; - style.resize = "none"; - style.zIndex = "-1"; - style.boxSizing = "border-box"; - document.body.appendChild(mirror); - mirrorRef.current = mirror; - return () => { - mirror.remove(); - mirrorRef.current = null; - }; - }, []); - - const measure = useCallback( - (text: string) => { - const mirror = mirrorRef.current; - const source = textareaRef.current; - if (!mirror || !source || typeof window === "undefined") return; - if (!(source instanceof HTMLElement)) return; - - const cs = window.getComputedStyle(source); - const ms = mirror.style; - for (const prop of COPIED_STYLES) { - ms[prop] = cs[prop]; - } - ms.width = `${source.clientWidth}px`; - - const { - minHeight: currentMinHeight, - maxHeight: currentMaxHeight, - onHeight: currentOnHeight, - } = paramsRef.current; - // Trailing newline is collapsed by textarea measurement — pad with a space. - mirror.value = text.endsWith("\n") ? `${text} ` : text; - - const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight)); - currentOnHeight(next); - }, - [textareaRef], - ); - - useLayoutEffect(() => { - measure(value); - }, [maxHeight, minHeight, value, measure]); - - useEffect(() => { - const source = textareaRef.current; - if (!source || !(source instanceof HTMLElement)) return; - if (typeof ResizeObserver === "undefined") return; - let previousWidth = source.clientWidth; - const observer = new ResizeObserver(() => { - const nextWidth = source.clientWidth; - if (Math.abs(nextWidth - previousWidth) < 1) return; - previousWidth = nextWidth; - measure(paramsRef.current.value); - }); - observer.observe(source); - return () => observer.disconnect(); - }, [textareaRef, measure]); - - return measure; -} diff --git a/packages/app/src/composer/input/height-state.test.ts b/packages/app/src/composer/input/height-state.test.ts new file mode 100644 index 0000000000..af15dfe61e --- /dev/null +++ b/packages/app/src/composer/input/height-state.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { reduceNativeComposerHeight } from "./height-state"; + +const bounds = { minHeight: 30, maxHeight: 240 }; + +describe("native composer height", () => { + it("grows a fresh composer from the content size exposed during caret scrolling", () => { + expect( + reduceNativeComposerHeight(30, bounds, { + type: "content-measured", + height: 131, + }), + ).toBe(131); + }); + + it("remeasures a restored multiline draft before applying its intrinsic height", () => { + const invalidatedHeight = reduceNativeComposerHeight(30, bounds, { type: "remeasure" }); + + expect(invalidatedHeight).toBeGreaterThan(30); + expect( + reduceNativeComposerHeight(invalidatedHeight, bounds, { + type: "content-measured", + height: 173, + }), + ).toBe(173); + }); + + it("shrinks a grown composer after deletion and the resulting native measurement", () => { + const invalidatedHeight = reduceNativeComposerHeight(240, bounds, { + type: "text-changed", + previousText: "one\ntwo\nthree", + nextText: "one", + }); + + expect(invalidatedHeight).toBeLessThan(240); + expect( + reduceNativeComposerHeight(invalidatedHeight, bounds, { + type: "content-measured", + height: 50, + }), + ).toBe(50); + }); + + it("does not disturb height while text grows", () => { + expect( + reduceNativeComposerHeight(120, bounds, { + type: "text-changed", + previousText: "one", + nextText: "one two", + }), + ).toBe(120); + }); + + it("clamps measurements and resets to the current bounds", () => { + expect( + reduceNativeComposerHeight(30, bounds, { + type: "content-measured", + height: 999, + }), + ).toBe(240); + expect(reduceNativeComposerHeight(180, bounds, { type: "reset" })).toBe(30); + }); +}); diff --git a/packages/app/src/composer/input/height-state.ts b/packages/app/src/composer/input/height-state.ts new file mode 100644 index 0000000000..e8c30b221f --- /dev/null +++ b/packages/app/src/composer/input/height-state.ts @@ -0,0 +1,41 @@ +export interface ComposerHeightBounds { + minHeight: number; + maxHeight: number; +} + +export type NativeComposerHeightEvent = + | { type: "content-measured"; height: number } + | { type: "text-changed"; previousText: string; nextText: string } + | { type: "remeasure" } + | { type: "reset" }; + +export function clampComposerHeight(height: number, bounds: ComposerHeightBounds): number { + return Math.max(bounds.minHeight, Math.min(bounds.maxHeight, height)); +} + +export function reduceNativeComposerHeight( + currentHeight: number, + bounds: ComposerHeightBounds, + event: NativeComposerHeightEvent, +): number { + if (event.type === "reset") return bounds.minHeight; + + if (event.type === "content-measured") { + if (!Number.isFinite(event.height) || event.height <= 0) return currentHeight; + return clampComposerHeight(event.height, bounds); + } + + if (event.type === "remeasure") { + const direction = currentHeight <= bounds.minHeight ? 1 : -1; + return clampComposerHeight(currentHeight + direction, bounds); + } + + if (event.nextText.length >= event.previousText.length || currentHeight <= bounds.minHeight) { + return currentHeight; + } + + // Fabric can retain the old UITextView content size after deletion while the text still fits + // inside its frame. Changing that frame invalidates layout; the following content-size event + // supplies the real smaller height. + return reduceNativeComposerHeight(currentHeight, bounds, { type: "remeasure" }); +} diff --git a/packages/app/src/composer/input/height-style.test.ts b/packages/app/src/composer/input/height-style.test.ts deleted file mode 100644 index 420d5bf3d2..0000000000 --- a/packages/app/src/composer/input/height-style.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style"; - -describe("composer input height style", () => { - it("applies the measured height alongside its bounds", () => { - expect( - resolveComposerInputHeightStyle({ - inputHeight: 96, - minInputHeight: 30, - maxInputHeight: 240, - applyMeasuredHeight: true, - }), - ).toEqual({ - height: 96, - minHeight: 30, - maxHeight: 240, - }); - }); - - it("lets a native multiline input expose its intrinsic content height", () => { - expect( - resolveComposerInputHeightStyle({ - inputHeight: 96, - minInputHeight: 30, - maxInputHeight: 240, - applyMeasuredHeight: false, - }), - ).toEqual({ - minHeight: 30, - maxHeight: 240, - }); - }); - - it("keeps native measurement unconstrained until the composer reaches its cap", () => { - expect(shouldScrollComposerInput({ inputHeight: 30, maxInputHeight: 240 })).toBe(false); - expect(shouldScrollComposerInput({ inputHeight: 240, maxInputHeight: 240 })).toBe(true); - }); -}); diff --git a/packages/app/src/composer/input/height-style.ts b/packages/app/src/composer/input/height-style.ts deleted file mode 100644 index 5d2635c86b..0000000000 --- a/packages/app/src/composer/input/height-style.ts +++ /dev/null @@ -1,24 +0,0 @@ -interface ComposerInputHeightStyleInput { - inputHeight: number; - minInputHeight: number; - maxInputHeight: number; - applyMeasuredHeight: boolean; -} - -export function resolveComposerInputHeightStyle(input: ComposerInputHeightStyleInput) { - // Native must remain intrinsically sized below the cap so - // onContentSizeChange can observe growth. Web gets its height from the DOM - // mirror and therefore needs that measurement applied explicitly. - return { - ...(input.applyMeasuredHeight ? { height: input.inputHeight } : {}), - minHeight: input.minInputHeight, - maxHeight: input.maxInputHeight, - }; -} - -export function shouldScrollComposerInput(input: { - inputHeight: number; - maxInputHeight: number; -}): boolean { - return input.inputHeight >= input.maxInputHeight; -} diff --git a/packages/app/src/composer/input/height.d.ts b/packages/app/src/composer/input/height.d.ts new file mode 100644 index 0000000000..71d65ad153 --- /dev/null +++ b/packages/app/src/composer/input/height.d.ts @@ -0,0 +1 @@ +export * from "./height.native"; diff --git a/packages/app/src/composer/input/height.native.ts b/packages/app/src/composer/input/height.native.ts new file mode 100644 index 0000000000..eeafad1f46 --- /dev/null +++ b/packages/app/src/composer/input/height.native.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { RefObject } from "react"; +import type { + NativeSyntheticEvent, + TextInputContentSizeChangeEventData, + TextInputScrollEventData, + TextStyle, +} from "react-native"; +import { reduceNativeComposerHeight, type NativeComposerHeightEvent } from "./height-state"; + +interface ComposerHeightArgs { + value: string; + textareaRef: RefObject; + minHeight: number; + maxHeight: number; + onHeightChange?: (height: number) => void; +} + +interface ComposerHeightResult { + style: TextStyle; + scrollEnabled: boolean; + onTextChange: (previousText: string, nextText: string) => void; + onContentSizeChange: (event: NativeSyntheticEvent) => void; + onScroll: (event: NativeSyntheticEvent) => void; + reset: () => void; +} + +export function useComposerHeight({ + value, + minHeight, + maxHeight, + onHeightChange, +}: ComposerHeightArgs): ComposerHeightResult { + const [height, setHeight] = useState(minHeight); + const heightRef = useRef(minHeight); + const didRemeasureInitialValueRef = useRef(false); + const onHeightChangeRef = useRef(onHeightChange); + onHeightChangeRef.current = onHeightChange; + + const apply = useCallback( + (event: NativeComposerHeightEvent) => { + const nextHeight = reduceNativeComposerHeight( + heightRef.current, + { minHeight, maxHeight }, + event, + ); + if (Math.abs(nextHeight - heightRef.current) < 1) return; + heightRef.current = nextHeight; + setHeight(nextHeight); + onHeightChangeRef.current?.(nextHeight); + }, + [maxHeight, minHeight], + ); + + useEffect(() => { + apply({ type: "content-measured", height: heightRef.current }); + }, [apply]); + + useEffect(() => { + if (didRemeasureInitialValueRef.current || value.length === 0) return; + didRemeasureInitialValueRef.current = true; + apply({ type: "remeasure" }); + }, [apply, value]); + + const onTextChange = useCallback( + (previousText: string, nextText: string) => { + apply({ type: "text-changed", previousText, nextText }); + }, + [apply], + ); + + const onContentSizeChange = useCallback( + (event: NativeSyntheticEvent) => { + apply({ type: "content-measured", height: event.nativeEvent.contentSize.height }); + }, + [apply], + ); + + const onScroll = useCallback( + (event: NativeSyntheticEvent) => { + // iOS Fabric can omit the first content-size callback when a fixed-height input overflows. + // The caret-scroll event still carries the actual size and bootstraps the same state. + const nativeEvent = event.nativeEvent as TextInputScrollEventData & { + contentSize?: { width: number; height: number }; + }; + const contentHeight = nativeEvent.contentSize?.height; + if (contentHeight !== undefined) { + apply({ type: "content-measured", height: contentHeight }); + } + }, + [apply], + ); + + const reset = useCallback(() => apply({ type: "reset" }), [apply]); + const style = useMemo(() => ({ height, minHeight, maxHeight }), [height, maxHeight, minHeight]); + + // A non-scrolling UITextView reports its frame as contentSize. Scrolling stays enabled so + // caret overflow can expose the intrinsic size through onScroll. + return { style, scrollEnabled: true, onTextChange, onContentSizeChange, onScroll, reset }; +} diff --git a/packages/app/src/composer/input/height.web.ts b/packages/app/src/composer/input/height.web.ts new file mode 100644 index 0000000000..4692e651a9 --- /dev/null +++ b/packages/app/src/composer/input/height.web.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { RefObject } from "react"; +import type { + NativeSyntheticEvent, + TextInputContentSizeChangeEventData, + TextInputScrollEventData, + TextStyle, +} from "react-native"; +import { clampComposerHeight } from "./height-state"; + +interface ComposerHeightArgs { + value: string; + textareaRef: RefObject; + minHeight: number; + maxHeight: number; + onHeightChange?: (height: number) => void; +} + +interface ComposerHeightResult { + style: TextStyle; + scrollEnabled: boolean; + onTextChange: (previousText: string, nextText: string) => void; + onContentSizeChange: (event: NativeSyntheticEvent) => void; + onScroll: (event: NativeSyntheticEvent) => void; + reset: () => void; +} + +const COPIED_STYLES = [ + "fontFamily", + "fontSize", + "fontWeight", + "fontStyle", + "fontVariant", + "lineHeight", + "letterSpacing", + "wordSpacing", + "textTransform", + "textIndent", + "whiteSpace", + "wordWrap", + "overflowWrap", + "wordBreak", + "tabSize", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", +] as const; + +export function useComposerHeight({ + value, + textareaRef, + minHeight, + maxHeight, + onHeightChange, +}: ComposerHeightArgs): ComposerHeightResult { + const [height, setHeight] = useState(minHeight); + const heightRef = useRef(minHeight); + const paramsRef = useRef({ value, minHeight, maxHeight, onHeightChange }); + paramsRef.current = { value, minHeight, maxHeight, onHeightChange }; + const mirrorRef = useRef(null); + + const setBoundedHeight = useCallback((nextHeight: number) => { + const { + minHeight: currentMin, + maxHeight: currentMax, + onHeightChange: onChange, + } = paramsRef.current; + const bounded = clampComposerHeight(nextHeight, { + minHeight: currentMin, + maxHeight: currentMax, + }); + if (Math.abs(heightRef.current - bounded) < 1) return; + heightRef.current = bounded; + setHeight(bounded); + onChange?.(bounded); + }, []); + + const measure = useCallback( + (text: string) => { + const mirror = mirrorRef.current; + const source = textareaRef.current; + if (!mirror || !source || typeof window === "undefined") return; + + const computedStyle = window.getComputedStyle(source); + for (const property of COPIED_STYLES) { + mirror.style[property] = computedStyle[property]; + } + mirror.style.width = `${source.clientWidth}px`; + mirror.value = text.endsWith("\n") ? `${text} ` : text; + setBoundedHeight(mirror.scrollHeight); + }, + [setBoundedHeight, textareaRef], + ); + + useEffect(() => { + if (typeof document === "undefined") return; + const mirror = document.createElement("textarea"); + mirror.setAttribute("aria-hidden", "true"); + mirror.setAttribute("tabindex", "-1"); + mirror.readOnly = true; + mirror.rows = 1; + Object.assign(mirror.style, { + position: "absolute", + top: "0", + left: "0", + visibility: "hidden", + pointerEvents: "none", + overflow: "hidden", + border: "0", + margin: "0", + resize: "none", + zIndex: "-1", + boxSizing: "border-box", + }); + document.body.appendChild(mirror); + mirrorRef.current = mirror; + measure(paramsRef.current.value); + return () => { + mirror.remove(); + mirrorRef.current = null; + }; + }, [measure]); + + useLayoutEffect(() => { + measure(value); + }, [maxHeight, minHeight, value, measure]); + + useEffect(() => { + const source = textareaRef.current; + if (!source || typeof ResizeObserver === "undefined") return; + let previousWidth = source.clientWidth; + const observer = new ResizeObserver(() => { + const nextWidth = source.clientWidth; + if (Math.abs(nextWidth - previousWidth) < 1) return; + previousWidth = nextWidth; + measure(paramsRef.current.value); + }); + observer.observe(source); + return () => observer.disconnect(); + }, [measure, textareaRef]); + + const onTextChange = useCallback( + (_previousText: string, nextText: string) => measure(nextText), + [measure], + ); + const reset = useCallback(() => setBoundedHeight(minHeight), [minHeight, setBoundedHeight]); + const onContentSizeChange = useCallback( + (_event: NativeSyntheticEvent) => undefined, + [], + ); + const onScroll = useCallback( + (_event: NativeSyntheticEvent) => undefined, + [], + ); + const style = useMemo(() => ({ height, minHeight, maxHeight }), [height, maxHeight, minHeight]); + + return { + style, + scrollEnabled: height >= maxHeight, + onTextChange, + onContentSizeChange, + onScroll, + reset, + }; +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 4b79b8166d..ad08d16166 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -6,6 +6,7 @@ import { NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, + TextInputScrollEventData, TextInputSelectionChangeEventData, } from "react-native"; import { @@ -55,8 +56,7 @@ import { isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; -import { useComposerHeightMirror } from "./height-mirror"; -import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style"; +import { useComposerHeight } from "./height"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import type { NativePastedFile } from "@/composer/native-pasted-image"; import { @@ -641,6 +641,7 @@ interface ComposerTextSurfaceProps { scrollEnabled: boolean; autoFocus: boolean; onContentSizeChange: (event: NativeSyntheticEvent) => void; + onScroll: (event: NativeSyntheticEvent) => void; onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined; onSelectionChange: (event: NativeSyntheticEvent) => void; onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined; @@ -680,6 +681,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen multiline scrollEnabled={props.scrollEnabled} onContentSizeChange={props.onContentSizeChange} + onScroll={props.onScroll} editable={props.editable} onKeyPress={props.onKeyPress} onSelectionChange={props.onSelectionChange} @@ -1200,7 +1202,6 @@ export const MessageInput = forwardRef( const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); const dictationToggleKeys = useShortcutKeys("dictation-toggle"); const focusInputKeys = useShortcutKeys("focus-message-input"); - const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const [isInputFocused, setIsInputFocused] = useState(false); // Web text is DOM-owned between deferred draft publications. The action button only needs // this boundary, so publish empty/non-empty transitions without rerendering for every key. @@ -1214,6 +1215,21 @@ export const MessageInput = forwardRef( const valueRef = useRef(value); const selectionRef = useRef({ start: value.length, end: value.length }); const appliedTextReplacementKeyRef = useRef(textReplacementKey); + const webTextareaRef = useRef(null); + const { + style: composerHeightStyle, + scrollEnabled: isComposerScrollEnabled, + onTextChange: updateComposerHeightForText, + onContentSizeChange: handleComposerContentSizeChange, + onScroll: handleComposerScroll, + reset: resetComposerHeight, + } = useComposerHeight({ + value, + textareaRef: webTextareaRef, + minHeight: MIN_INPUT_HEIGHT, + maxHeight: maxInputHeight, + onHeightChange, + }); const updateLiveTextPresence = useCallback((text: string) => { const nextHasLiveText = text.trim().length > 0; @@ -1224,13 +1240,14 @@ export const MessageInput = forwardRef( const replaceText = useCallback( (nextText: string, selection?: { start: number; end: number }) => { + updateComposerHeightForText(valueRef.current, nextText); valueRef.current = nextText; updateLiveTextPresence(nextText); selectionRef.current = selection ?? { start: nextText.length, end: nextText.length }; textInputRef.current?.replaceText(nextText, selection); onChangeText(nextText); }, - [onChangeText, updateLiveTextPresence], + [onChangeText, updateComposerHeightForText, updateLiveTextPresence], ); useImperativeHandle(ref, () => ({ @@ -1260,7 +1277,6 @@ export const MessageInput = forwardRef( }), getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null), })); - const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const sendAfterTranscriptRef = useRef(false); const serverInfo = useSessionStore( useCallback( @@ -1277,10 +1293,11 @@ export const MessageInput = forwardRef( useEffect(() => { if (appliedTextReplacementKeyRef.current === textReplacementKey) return; appliedTextReplacementKeyRef.current = textReplacementKey; + updateComposerHeightForText(valueRef.current, value); valueRef.current = value; updateLiveTextPresence(value); textInputRef.current?.replaceText(value); - }, [textReplacementKey, updateLiveTextPresence, value]); + }, [textReplacementKey, updateComposerHeightForText, updateLiveTextPresence, value]); useEffect(() => { return () => { @@ -1477,10 +1494,8 @@ export const MessageInput = forwardRef( ]); const minimizeInputHeight = useCallback(() => { - inputHeightRef.current = MIN_INPUT_HEIGHT; - setInputHeight(MIN_INPUT_HEIGHT); - onHeightChange?.(MIN_INPUT_HEIGHT); - }, [onHeightChange]); + resetComposerHeight(); + }, [resetComposerHeight]); const handleSendMessage = useCallback(() => { const liveValue = textInputRef.current?.getText() ?? valueRef.current; @@ -1548,8 +1563,6 @@ export const MessageInput = forwardRef( [], ); - const webTextareaRef = useRef(null); - useLayoutEffect(() => { if (isWeb) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; @@ -1565,37 +1578,6 @@ export const MessageInput = forwardRef( onAddImages, }); - const setBoundedInputHeight = useCallback( - (nextHeight: number) => { - const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(maxInputHeight, nextHeight)); - if (Math.abs(inputHeightRef.current - bounded) < 1) return; - inputHeightRef.current = bounded; - setInputHeight(bounded); - onHeightChange?.(bounded); - }, - [maxInputHeight, onHeightChange], - ); - - useEffect(() => { - setBoundedInputHeight(inputHeightRef.current); - }, [setBoundedInputHeight]); - - const measureComposerHeight = useComposerHeightMirror({ - value, - textareaRef: webTextareaRef, - minHeight: MIN_INPUT_HEIGHT, - maxHeight: maxInputHeight, - onHeight: setBoundedInputHeight, - }); - - const handleContentSizeChange = useCallback( - (event: NativeSyntheticEvent) => { - if (isWeb) return; - setBoundedInputHeight(event.nativeEvent.contentSize.height); - }, - [setBoundedInputHeight], - ); - const handleSelectionChange = useCallback( (event: NativeSyntheticEvent) => { const start = event.nativeEvent.selection?.start ?? 0; @@ -1682,12 +1664,12 @@ export const MessageInput = forwardRef( const handleInputChange = useCallback( (nextValue: string) => { + updateComposerHeightForText(valueRef.current, nextValue); valueRef.current = nextValue; - measureComposerHeight(nextValue); updateLiveTextPresence(nextValue); onChangeText(nextValue); }, - [measureComposerHeight, onChangeText, updateLiveTextPresence], + [onChangeText, updateComposerHeightForText, updateLiveTextPresence], ); const handleInputFocus = useCallback(() => { @@ -1748,17 +1730,8 @@ export const MessageInput = forwardRef( // `fontFamily` here is silently dropped while every other property lands. // An inline style outranks both classes. See docs/unistyles.md. const textInputStyle = useMemo( - () => [ - styles.textInput, - mode.isMonospace && styles.textInputMonospace, - resolveComposerInputHeightStyle({ - inputHeight, - minInputHeight: MIN_INPUT_HEIGHT, - maxInputHeight, - applyMeasuredHeight: isWeb, - }), - ], - [inputHeight, maxInputHeight, mode.isMonospace], + () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle], + [composerHeightStyle, mode.isMonospace], ); // Static content has no textarea to mirror, so it grows with its own text // instead of the measured input height. @@ -1830,9 +1803,10 @@ export const MessageInput = forwardRef( onFocus={handleInputFocus} onBlur={handleInputBlur} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} - scrollEnabled={shouldScrollComposerInput({ inputHeight, maxInputHeight })} + scrollEnabled={isComposerScrollEnabled} autoFocus={false} - onContentSizeChange={handleContentSizeChange} + onContentSizeChange={handleComposerContentSizeChange} + onScroll={handleComposerScroll} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} onPasteImages={onPasteImages}