fix(app): composer textarea shrinks back down on web
Measuring scrollHeight on the live textarea with an explicit height set can only grow — it can never report a shorter content height. The old `height: auto` toggle flickered across browsers. Use a hidden off-DOM textarea that mirrors the composer's text-metric styles and width, and read its scrollHeight on value/resize changes. Gated by Metro file extension (.web / .native) so it's trivial to rip out later.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
export * from "./composer-height-mirror.native";
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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): void {
|
||||||
|
// No-op on native: onContentSizeChange drives height natively.
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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): 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(() => {
|
||||||
|
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 { value, minHeight, maxHeight, onHeight } = paramsRef.current;
|
||||||
|
// Trailing newline is collapsed by textarea measurement — pad with a space.
|
||||||
|
mirror.value = value.endsWith("\n") ? `${value} ` : value;
|
||||||
|
|
||||||
|
const next = Math.max(minHeight, Math.min(maxHeight, mirror.scrollHeight));
|
||||||
|
onHeight(next);
|
||||||
|
}, [textareaRef]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
measure();
|
||||||
|
}, [value, measure]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = textareaRef.current;
|
||||||
|
if (!source || !(source instanceof HTMLElement)) return;
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const observer = new ResizeObserver(() => measure());
|
||||||
|
observer.observe(source);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [textareaRef, measure]);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
markScrollInvestigationRender,
|
markScrollInvestigationRender,
|
||||||
} from "@/utils/scroll-jank-investigation";
|
} from "@/utils/scroll-jank-investigation";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||||
|
|
||||||
export type ImageAttachment = AttachmentMetadata;
|
export type ImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
@@ -330,7 +331,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||||
const baselineInputHeightRef = useRef<number | null>(null);
|
|
||||||
const overlayTransition = useSharedValue(0);
|
const overlayTransition = useSharedValue(0);
|
||||||
const sendAfterTranscriptRef = useRef(false);
|
const sendAfterTranscriptRef = useRef(false);
|
||||||
const valueRef = useRef(value);
|
const valueRef = useRef(value);
|
||||||
@@ -400,12 +400,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
} else {
|
} else {
|
||||||
onChangeText(nextValue);
|
onChangeText(nextValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWeb && typeof requestAnimationFrame === "function") {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
measureWebInputHeight("dictation");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
||||||
);
|
);
|
||||||
@@ -832,76 +826,27 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
};
|
};
|
||||||
}, [getWebTextArea]);
|
}, [getWebTextArea]);
|
||||||
|
|
||||||
function measureWebInputHeight(source: string): boolean {
|
|
||||||
if (!isWeb) return false;
|
|
||||||
const textarea = getWebTextArea();
|
|
||||||
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
|
|
||||||
const scrollHeight = textarea.scrollHeight ?? 0;
|
|
||||||
|
|
||||||
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
|
|
||||||
baselineInputHeightRef.current = scrollHeight;
|
|
||||||
logWebStickyBottom("composer_baseline_measured", {
|
|
||||||
source,
|
|
||||||
baseline: scrollHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT;
|
|
||||||
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline;
|
|
||||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget));
|
|
||||||
|
|
||||||
const previousHeight = inputHeightRef.current;
|
|
||||||
if (Math.abs(previousHeight - bounded) >= 1) {
|
|
||||||
inputHeightRef.current = bounded;
|
|
||||||
setInputHeight(bounded);
|
|
||||||
onHeightChange?.(bounded);
|
|
||||||
logWebStickyBottom("composer_height_changed", {
|
|
||||||
source,
|
|
||||||
previousHeight,
|
|
||||||
nextHeight: bounded,
|
|
||||||
scrollHeight,
|
|
||||||
clientHeight: textarea.clientHeight ?? null,
|
|
||||||
offsetHeight: textarea.offsetHeight ?? null,
|
|
||||||
baseline,
|
|
||||||
rawTarget,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBoundedInputHeight(nextHeight: number) {
|
function setBoundedInputHeight(nextHeight: number) {
|
||||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
|
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
|
||||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
|
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
|
||||||
const previousHeight = inputHeightRef.current;
|
|
||||||
inputHeightRef.current = bounded;
|
inputHeightRef.current = bounded;
|
||||||
setInputHeight(bounded);
|
setInputHeight(bounded);
|
||||||
onHeightChange?.(bounded);
|
onHeightChange?.(bounded);
|
||||||
logWebStickyBottom("composer_height_changed_native", {
|
|
||||||
previousHeight,
|
|
||||||
nextHeight: bounded,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useComposerHeightMirror({
|
||||||
|
value,
|
||||||
|
textareaRef: webTextareaRef,
|
||||||
|
minHeight: MIN_INPUT_HEIGHT,
|
||||||
|
maxHeight: MAX_INPUT_HEIGHT,
|
||||||
|
onHeight: setBoundedInputHeight,
|
||||||
|
});
|
||||||
|
|
||||||
function handleContentSizeChange(
|
function handleContentSizeChange(
|
||||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||||
) {
|
) {
|
||||||
const contentHeight = event.nativeEvent.contentSize.height;
|
if (isWeb) return;
|
||||||
if (isWeb) {
|
setBoundedInputHeight(event.nativeEvent.contentSize.height);
|
||||||
logWebStickyBottom("composer_content_size_change", {
|
|
||||||
reportedHeight: contentHeight,
|
|
||||||
});
|
|
||||||
if (baselineInputHeightRef.current === null && contentHeight > 0) {
|
|
||||||
baselineInputHeightRef.current = contentHeight;
|
|
||||||
logWebStickyBottom("composer_baseline_measured", {
|
|
||||||
source: "contentSizeChange",
|
|
||||||
baseline: contentHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setBoundedInputHeight(contentHeight);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBoundedInputHeight(contentHeight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user