mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
fix(app): restore composer height lifecycle (#3740)
Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms.
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export * from "./height-mirror.native";
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<HTMLElement | null>;
|
||||
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<HTMLTextAreaElement | null>(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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./height.native";
|
||||
@@ -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<unknown>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeightChange?: (height: number) => void;
|
||||
}
|
||||
|
||||
interface ComposerHeightResult {
|
||||
style: TextStyle;
|
||||
scrollEnabled: boolean;
|
||||
onTextChange: (previousText: string, nextText: string) => void;
|
||||
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
|
||||
onScroll: (event: NativeSyntheticEvent<TextInputScrollEventData>) => 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<TextInputContentSizeChangeEventData>) => {
|
||||
apply({ type: "content-measured", height: event.nativeEvent.contentSize.height });
|
||||
},
|
||||
[apply],
|
||||
);
|
||||
|
||||
const onScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<TextInputScrollEventData>) => {
|
||||
// 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 };
|
||||
}
|
||||
@@ -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<HTMLElement | null>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeightChange?: (height: number) => void;
|
||||
}
|
||||
|
||||
interface ComposerHeightResult {
|
||||
style: TextStyle;
|
||||
scrollEnabled: boolean;
|
||||
onTextChange: (previousText: string, nextText: string) => void;
|
||||
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
|
||||
onScroll: (event: NativeSyntheticEvent<TextInputScrollEventData>) => 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<HTMLTextAreaElement | null>(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<TextInputContentSizeChangeEventData>) => undefined,
|
||||
[],
|
||||
);
|
||||
const onScroll = useCallback(
|
||||
(_event: NativeSyntheticEvent<TextInputScrollEventData>) => undefined,
|
||||
[],
|
||||
);
|
||||
const style = useMemo(() => ({ height, minHeight, maxHeight }), [height, maxHeight, minHeight]);
|
||||
|
||||
return {
|
||||
style,
|
||||
scrollEnabled: height >= maxHeight,
|
||||
onTextChange,
|
||||
onContentSizeChange,
|
||||
onScroll,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -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<TextInputContentSizeChangeEventData>) => void;
|
||||
onScroll: (event: NativeSyntheticEvent<TextInputScrollEventData>) => void;
|
||||
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
|
||||
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => 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<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
const valueRef = useRef(value);
|
||||
const selectionRef = useRef({ start: value.length, end: value.length });
|
||||
const appliedTextReplacementKeyRef = useRef(textReplacementKey);
|
||||
const webTextareaRef = useRef<HTMLElement | null>(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<MessageInputRef, MessageInputProps>(
|
||||
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
}),
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
]);
|
||||
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
[],
|
||||
);
|
||||
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isWeb) {
|
||||
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||
@@ -1565,37 +1578,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
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<TextInputContentSizeChangeEventData>) => {
|
||||
if (isWeb) return;
|
||||
setBoundedInputHeight(event.nativeEvent.contentSize.height);
|
||||
},
|
||||
[setBoundedInputHeight],
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
||||
const start = event.nativeEvent.selection?.start ?? 0;
|
||||
@@ -1682,12 +1664,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
// `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<MessageInputRef, MessageInputProps>(
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user