From 3e26c098099e7883dd4ebcfc1e8afd327c579918 Mon Sep 17 00:00:00 2001 From: macodev00 Date: Sun, 20 Sep 2026 09:02:01 +0000 Subject: [PATCH 1/4] fix(mobile): keep Android composer caret in view After the expanded composer hits its max height, typing no longer scrolled the current line into view, so long prompts were entered blind. Grow the editor from the native content-size event up to that cap and call bringPointIntoView after text, selection, and layout updates. --- .../t3composereditor/T3ComposerEditorView.kt | 25 ++++++ .../src/native/T3ComposerEditor.native.tsx | 8 +- .../src/native/composerEditorLayout.test.ts | 80 +++++++++++++++++++ .../mobile/src/native/composerEditorLayout.ts | 41 ++++++++++ .../src/native/useComposerEditorAutoHeight.ts | 52 ++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/native/composerEditorLayout.test.ts create mode 100644 apps/mobile/src/native/composerEditorLayout.ts create mode 100644 apps/mobile/src/native/useComposerEditorAutoHeight.ts diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 35d796b7876f..d75d16c55e3e 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -62,6 +62,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private var autoCorrect = true private var spellCheck = true private var nativeEventCount = 0 + private var caretScrollPosted = false init { editor.setBackgroundColor(Color.TRANSPARENT) @@ -173,12 +174,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( ), ) emitContentSizeIfNeeded() + scrollCaretIntoView() } }, ) editor.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> if (right - left != oldRight - oldLeft) applyTokenSpans() emitContentSizeIfNeeded() + scrollCaretIntoView() } addView( editor, @@ -226,6 +229,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( applyingNativeValue = false } emitContentSizeIfNeeded() + // Echo re-renders re-apply chip spans without changing text or caret. + // Scrolling on those would yank a manual review scroll back to the caret. + if (valueChanged || requestedSelection != null) { + scrollCaretIntoView() + } } fun setThemeJson(themeJson: String) { @@ -362,6 +370,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( // state, so a no-op assignment must be skipped. if (editor.selectionStart == safeStart && editor.selectionEnd == safeEnd) return editor.setSelection(safeStart, safeEnd) + scrollCaretIntoView() } private fun updateInputFlags() { @@ -419,6 +428,22 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( "eventCount" to nativeEventCount, ), ) + scrollCaretIntoView() + } + + /** + * Keep the caret on screen after typing, caret moves, layout changes, and + * controlled text resets. setScrollEnabled only toggles the scrollbar. + */ + private fun scrollCaretIntoView() { + if (caretScrollPosted) return + caretScrollPosted = true + editor.post { + caretScrollPosted = false + if (editor.layout == null || editor.height <= 0) return@post + val offset = editor.selectionEnd.coerceIn(0, editor.length()) + editor.bringPointIntoView(offset) + } } private fun emitContentSizeIfNeeded() { diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 7f18bae0570a..11a646d429fe 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -36,6 +36,7 @@ import { resolveComposerControlledEventCount, type ComposerNativeEventSnapshot, } from "./composerEditorRevision"; +import { useComposerEditorAutoHeight } from "./useComposerEditorAutoHeight"; import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; const NATIVE_MODULE_NAME = "T3ComposerEditor"; @@ -101,6 +102,9 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; + readonly onComposerContentSizeChange?: ( + event: NativeSyntheticEvent<{ readonly height: number }>, + ) => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -264,8 +268,9 @@ export function ComposerEditor({ }); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; const regularFontFamily = useFontFamily("regular"); + const { onContentHeight, resolvedStyle } = useComposerEditorAutoHeight(style); return ( - + onContentHeight(event.nativeEvent.height)} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( event.nativeEvent.eventCount, diff --git a/apps/mobile/src/native/composerEditorLayout.test.ts b/apps/mobile/src/native/composerEditorLayout.test.ts new file mode 100644 index 000000000000..82a674edf0f2 --- /dev/null +++ b/apps/mobile/src/native/composerEditorLayout.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { composerEditorLaidOutHeight, verticalPaddingFromViewStyle } from "./composerEditorLayout"; + +describe("composer editor auto-height", () => { + it("uses a collapsed fixed height instead of growing with content", () => { + expect( + composerEditorLaidOutHeight({ + contentHeight: 240, + height: 36, + minHeight: 72, + maxHeight: 160, + verticalPadding: 0, + }), + ).toBe(36); + }); + + it("grows from the minimum until the content fills the expanded frame", () => { + expect( + composerEditorLaidOutHeight({ + contentHeight: 20, + minHeight: 72, + maxHeight: 160, + verticalPadding: 8, + }), + ).toBe(72); + expect( + composerEditorLaidOutHeight({ + contentHeight: 100, + minHeight: 72, + maxHeight: 160, + verticalPadding: 8, + }), + ).toBe(108); + }); + + it("does not add Yoga padding when the native view fills the bounds", () => { + expect( + composerEditorLaidOutHeight({ + contentHeight: 100, + minHeight: 72, + maxHeight: 160, + verticalPadding: 0, + }), + ).toBe(100); + }); + + it("stops at maxHeight so a long prompt scrolls inside the editor", () => { + expect( + composerEditorLaidOutHeight({ + contentHeight: 400, + minHeight: 72, + maxHeight: 160, + verticalPadding: 8, + }), + ).toBe(160); + }); + + it("counts vertical padding once from paddingVertical", () => { + expect(verticalPaddingFromViewStyle({ paddingVertical: 4 })).toBe(8); + expect(verticalPaddingFromViewStyle({ paddingTop: 4, paddingBottom: 6 })).toBe(10); + expect(verticalPaddingFromViewStyle({ padding: 3 })).toBe(6); + }); + + it("lets paddingTop and paddingBottom override paddingVertical", () => { + expect( + verticalPaddingFromViewStyle({ + padding: 2, + paddingVertical: 4, + paddingTop: 1, + }), + ).toBe(5); + expect( + verticalPaddingFromViewStyle({ + paddingVertical: 4, + paddingBottom: 10, + }), + ).toBe(14); + }); +}); diff --git a/apps/mobile/src/native/composerEditorLayout.ts b/apps/mobile/src/native/composerEditorLayout.ts new file mode 100644 index 000000000000..6df36d15ac5e --- /dev/null +++ b/apps/mobile/src/native/composerEditorLayout.ts @@ -0,0 +1,41 @@ +export function numericStyleLength(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * Vertical padding declared on the editor style. Android's wrapper View + * insets by this amount, so it belongs in the laid-out height. + */ +export function verticalPaddingFromViewStyle(style: { + readonly padding?: unknown; + readonly paddingVertical?: unknown; + readonly paddingTop?: unknown; + readonly paddingBottom?: unknown; +}): number { + const padding = numericStyleLength(style.padding) ?? 0; + const paddingVertical = numericStyleLength(style.paddingVertical) ?? padding; + const top = numericStyleLength(style.paddingTop) ?? paddingVertical; + const bottom = numericStyleLength(style.paddingBottom) ?? paddingVertical; + return top + bottom; +} + +/** + * Height of a min/max-bounded composer editor. A fixed `height` is a collapsed + * frame and wins. Otherwise the native content height grows from min to max so + * the caret is not clipped inside the minHeight box. + */ +export function composerEditorLaidOutHeight(input: { + readonly contentHeight: number; + readonly minHeight?: number; + readonly maxHeight?: number; + readonly height?: number; + readonly verticalPadding: number; +}): number { + if (typeof input.height === "number") { + return input.height; + } + const minHeight = input.minHeight ?? 0; + const maxHeight = input.maxHeight ?? Number.POSITIVE_INFINITY; + const desired = Math.max(0, input.contentHeight) + input.verticalPadding; + return Math.min(maxHeight, Math.max(minHeight, desired)); +} diff --git a/apps/mobile/src/native/useComposerEditorAutoHeight.ts b/apps/mobile/src/native/useComposerEditorAutoHeight.ts new file mode 100644 index 000000000000..66f75be56d89 --- /dev/null +++ b/apps/mobile/src/native/useComposerEditorAutoHeight.ts @@ -0,0 +1,52 @@ +import { useCallback, useMemo, useState } from "react"; +import { StyleSheet, type StyleProp, type ViewStyle } from "react-native"; + +import { + composerEditorLaidOutHeight, + numericStyleLength, + verticalPaddingFromViewStyle, +} from "./composerEditorLayout"; + +const CONTENT_HEIGHT_EPSILON = 0.5; + +export function useComposerEditorAutoHeight(style: StyleProp | undefined) { + const flatStyle = StyleSheet.flatten(style) ?? {}; + const minHeight = numericStyleLength(flatStyle.minHeight); + const maxHeight = numericStyleLength(flatStyle.maxHeight); + const height = numericStyleLength(flatStyle.height); + const verticalPadding = verticalPaddingFromViewStyle(flatStyle); + const [contentHeight, setContentHeight] = useState(null); + + const onContentHeight = useCallback((nextHeight: number) => { + if (!Number.isFinite(nextHeight) || nextHeight < 0) { + return; + } + setContentHeight((current) => + current != null && Math.abs(current - nextHeight) < CONTENT_HEIGHT_EPSILON + ? current + : nextHeight, + ); + }, []); + + const laidOutHeight = useMemo(() => { + if (typeof height === "number" || (minHeight === undefined && maxHeight === undefined)) { + return undefined; + } + if (contentHeight == null) { + return undefined; + } + return composerEditorLaidOutHeight({ + contentHeight, + minHeight, + maxHeight, + verticalPadding, + }); + }, [contentHeight, height, maxHeight, minHeight, verticalPadding]); + + const resolvedStyle = useMemo( + () => (laidOutHeight == null ? style : [style, { height: laidOutHeight }]), + [laidOutHeight, style], + ); + + return { onContentHeight, resolvedStyle }; +} From 1084569a23feb6f2f48fbe34c4bf2c823a3d6d72 Mon Sep 17 00:00:00 2001 From: macodev00 Date: Sun, 20 Sep 2026 10:38:57 +0000 Subject: [PATCH 2/4] docs(mobile): add JSDoc on Android composer caret-scroll functions One-line JSDoc/TSDoc/KDoc on functions touched by the caret-into-view change so CodeRabbit docstring coverage can meet the 80% threshold. Co-authored-by: maco --- .../t3composereditor/T3ComposerEditorView.kt | 49 ++++-- .../src/native/T3ComposerEditor.native.tsx | 85 +++++++---- .../src/native/composerEditorLayout.test.ts | 139 ++++++++---------- .../mobile/src/native/composerEditorLayout.ts | 1 + .../src/native/useComposerEditorAutoHeight.ts | 48 ++++-- 5 files changed, 188 insertions(+), 134 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index d75d16c55e3e..33799e2f7c5d 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -20,6 +20,7 @@ import android.view.Gravity import android.view.GestureDetector import android.view.MotionEvent import android.view.KeyEvent +import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection @@ -153,14 +154,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } editor.addTextChangedListener( object : TextWatcher { + /** No-op; text is published after the edit lands. */ override fun beforeTextChanged( text: CharSequence?, start: Int, count: Int, after: Int ) = Unit + /** No-op; text is published after the edit lands. */ override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit + /** Publish the typed text and scroll the caret into view. */ override fun afterTextChanged(editable: Editable?) { if (applyingNativeValue) return val nextValue = editable.toString() @@ -178,11 +182,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } }, ) - editor.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> - if (right - left != oldRight - oldLeft) applyTokenSpans() - emitContentSizeIfNeeded() - scrollCaretIntoView() - } + editor.addOnLayoutChangeListener(::onEditorLayoutChanged) addView( editor, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), @@ -190,6 +190,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } @Suppress("ReturnCount") + /** Apply a controlled document and scroll the caret when text or selection changes. */ fun setControlledDocumentJson(documentJson: String) { val document = try { JSONObject(documentJson) @@ -236,6 +237,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } } + /** Apply text, placeholder, selection, and chip colors from the theme JSON. */ fun setThemeJson(themeJson: String) { try { val theme = JSONObject(themeJson) @@ -358,10 +360,12 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( imm?.hideSoftInputFromWindow(editor.windowToken, 0) } + /** Apply an explicit selection from the JS host. */ fun setSelection(start: Int, end: Int) { applySelection(start, end) } + /** Set the editor selection and scroll the caret into view. */ private fun applySelection(start: Int, end: Int) { val textLength = editor.text?.length ?: 0 val safeStart = start.coerceIn(0, textLength) @@ -373,6 +377,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( scrollCaretIntoView() } + /** Apply autocorrect and spell-check flags to the editor input type. */ private fun updateInputFlags() { var flags = InputType.TYPE_CLASS_TEXT or @@ -416,6 +421,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( "end" to maxOf(start, end).coerceAtLeast(0), ) + /** Publish a caret move and scroll it into view. */ private fun emitSelectionChange(start: Int, end: Int) { // Caret moves advance the revision counter like text edits do: a // controlled payload computed before this move is stale and must fail the @@ -431,6 +437,23 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( scrollCaretIntoView() } + /** Re-apply chips on width changes and keep the caret on screen after layout. */ + private fun onEditorLayoutChanged( + _view: View, + left: Int, + _top: Int, + right: Int, + _bottom: Int, + oldLeft: Int, + _oldTop: Int, + oldRight: Int, + _oldBottom: Int, + ) { + if (right - left != oldRight - oldLeft) applyTokenSpans() + emitContentSizeIfNeeded() + scrollCaretIntoView() + } + /** * Keep the caret on screen after typing, caret moves, layout changes, and * controlled text resets. setScrollEnabled only toggles the scrollbar. @@ -438,14 +461,18 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private fun scrollCaretIntoView() { if (caretScrollPosted) return caretScrollPosted = true - editor.post { - caretScrollPosted = false - if (editor.layout == null || editor.height <= 0) return@post - val offset = editor.selectionEnd.coerceIn(0, editor.length()) - editor.bringPointIntoView(offset) - } + editor.post(::bringCaretIntoViewNow) + } + + /** Apply bringPointIntoView after the current layout pass. */ + private fun bringCaretIntoViewNow() { + caretScrollPosted = false + if (editor.layout == null || editor.height <= 0) return + val offset = editor.selectionEnd.coerceIn(0, editor.length()) + editor.bringPointIntoView(offset) } + /** Emit native content height when the measured text height changes. */ private fun emitContentSizeIfNeeded() { val height = editor.layout?.height ?: editor.measuredHeight val contentHeight = height + contentInsetVertical * 2 diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 11a646d429fe..af97a65a39eb 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -99,9 +99,13 @@ interface NativeComposerEditorProps extends ViewProps { ) => void; readonly textPasteThresholdBytes: number; readonly maxInputChars: number; + /** Native clipboard text paste handler. */ readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; + /** Native focus handler. */ readonly onComposerFocus?: () => void; + /** Native blur handler. */ readonly onComposerBlur?: () => void; + /** Native content-height handler used for auto-height layout. */ readonly onComposerContentSizeChange?: ( event: NativeSyntheticEvent<{ readonly height: number }>, ) => void; @@ -109,15 +113,18 @@ interface NativeComposerEditorProps extends ViewProps { const NativeView = requireNativeView(NATIVE_MODULE_NAME); +/** Last path segment for mention chip labels. */ function basename(path: string): string { const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separator >= 0 ? path.slice(separator + 1) : path; } +/** Resolve a markdown file icon URI for mention chips. */ function fileIconUri(path: string): string { return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri; } +/** Native composer that sizes to content and forwards caret-safe layout to the host view. */ export function ComposerEditor({ ref, skills = EMPTY_SKILLS, @@ -269,6 +276,49 @@ export function ComposerEditor({ const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; const regularFontFamily = useFontFamily("regular"); const { onContentHeight, resolvedStyle } = useComposerEditorAutoHeight(style); + /** Forward native content-height events into the auto-height hook. */ + function onNativeContentSizeChange( + event: NativeSyntheticEvent<{ readonly height: number }>, + ) { + onContentHeight(event.nativeEvent.height); + } + /** Publish native text changes into the controlled React state. */ + function handleComposerChange(event: NativeEditorEvent) { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender(incrementRenderSequence); + } + /** Bump the native-event render token after acknowledging an edit. */ + function incrementRenderSequence(sequence: number): number { + return sequence + 1; + } + /** Publish native caret moves into the controlled React state. */ + function handleComposerSelectionChange(event: NativeSelectionEvent) { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + // Android emits the selection change mid-mutation, before the change + // event, so the payload can carry post-edit text. It must reach the + // parent alongside the acknowledged revision, or the next render + // stamps the stale draft at that revision and can re-apply it over + // the newer native text. + if (event.nativeEvent.value !== props.value) { + onChangeText(event.nativeEvent.value); + } + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender(incrementRenderSequence); + } return ( onContentHeight(event.nativeEvent.height)} - onComposerChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - onChangeText(event.nativeEvent.value); - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - forceNativeEventRender((sequence) => sequence + 1); - }} - onComposerSelectionChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - // Android emits the selection change mid-mutation, before the change - // event, so the payload can carry post-edit text. It must reach the - // parent alongside the acknowledged revision, or the next render - // stamps the stale draft at that revision and can re-apply it over - // the newer native text. - if (event.nativeEvent.value !== props.value) { - onChangeText(event.nativeEvent.value); - } - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - forceNativeEventRender((sequence) => sequence + 1); - }} + onComposerContentSizeChange={onNativeContentSizeChange} + onComposerChange={handleComposerChange} + onComposerSelectionChange={handleComposerSelectionChange} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} onComposerPasteContext={(event) => { diff --git a/apps/mobile/src/native/composerEditorLayout.test.ts b/apps/mobile/src/native/composerEditorLayout.test.ts index 82a674edf0f2..d847c50bb51e 100644 --- a/apps/mobile/src/native/composerEditorLayout.test.ts +++ b/apps/mobile/src/native/composerEditorLayout.test.ts @@ -1,80 +1,67 @@ -import { describe, expect, it } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; import { composerEditorLaidOutHeight, verticalPaddingFromViewStyle } from "./composerEditorLayout"; -describe("composer editor auto-height", () => { - it("uses a collapsed fixed height instead of growing with content", () => { - expect( - composerEditorLaidOutHeight({ - contentHeight: 240, - height: 36, - minHeight: 72, - maxHeight: 160, - verticalPadding: 0, - }), - ).toBe(36); - }); +/** Assert auto-height growth, max-height capping, and padding precedence. */ +function testComposerEditorAutoHeight() { + expect( + composerEditorLaidOutHeight({ + contentHeight: 240, + height: 36, + minHeight: 72, + maxHeight: 160, + verticalPadding: 0, + }), + ).toBe(36); + expect( + composerEditorLaidOutHeight({ + contentHeight: 20, + minHeight: 72, + maxHeight: 160, + verticalPadding: 8, + }), + ).toBe(72); + expect( + composerEditorLaidOutHeight({ + contentHeight: 100, + minHeight: 72, + maxHeight: 160, + verticalPadding: 8, + }), + ).toBe(108); + expect( + composerEditorLaidOutHeight({ + contentHeight: 100, + minHeight: 72, + maxHeight: 160, + verticalPadding: 0, + }), + ).toBe(100); + expect( + composerEditorLaidOutHeight({ + contentHeight: 400, + minHeight: 72, + maxHeight: 160, + verticalPadding: 8, + }), + ).toBe(160); + expect(verticalPaddingFromViewStyle({ paddingVertical: 4 })).toBe(8); + expect(verticalPaddingFromViewStyle({ paddingTop: 4, paddingBottom: 6 })).toBe(10); + expect(verticalPaddingFromViewStyle({ padding: 3 })).toBe(6); + expect( + verticalPaddingFromViewStyle({ + padding: 2, + paddingVertical: 4, + paddingTop: 1, + }), + ).toBe(5); + expect( + verticalPaddingFromViewStyle({ + paddingVertical: 4, + paddingBottom: 10, + }), + ).toBe(14); +} - it("grows from the minimum until the content fills the expanded frame", () => { - expect( - composerEditorLaidOutHeight({ - contentHeight: 20, - minHeight: 72, - maxHeight: 160, - verticalPadding: 8, - }), - ).toBe(72); - expect( - composerEditorLaidOutHeight({ - contentHeight: 100, - minHeight: 72, - maxHeight: 160, - verticalPadding: 8, - }), - ).toBe(108); - }); - - it("does not add Yoga padding when the native view fills the bounds", () => { - expect( - composerEditorLaidOutHeight({ - contentHeight: 100, - minHeight: 72, - maxHeight: 160, - verticalPadding: 0, - }), - ).toBe(100); - }); - - it("stops at maxHeight so a long prompt scrolls inside the editor", () => { - expect( - composerEditorLaidOutHeight({ - contentHeight: 400, - minHeight: 72, - maxHeight: 160, - verticalPadding: 8, - }), - ).toBe(160); - }); - - it("counts vertical padding once from paddingVertical", () => { - expect(verticalPaddingFromViewStyle({ paddingVertical: 4 })).toBe(8); - expect(verticalPaddingFromViewStyle({ paddingTop: 4, paddingBottom: 6 })).toBe(10); - expect(verticalPaddingFromViewStyle({ padding: 3 })).toBe(6); - }); - - it("lets paddingTop and paddingBottom override paddingVertical", () => { - expect( - verticalPaddingFromViewStyle({ - padding: 2, - paddingVertical: 4, - paddingTop: 1, - }), - ).toBe(5); - expect( - verticalPaddingFromViewStyle({ - paddingVertical: 4, - paddingBottom: 10, - }), - ).toBe(14); - }); -}); +/** Register auto-height layout assertions with the test runner. */ +it("composer editor auto-height", testComposerEditorAutoHeight); diff --git a/apps/mobile/src/native/composerEditorLayout.ts b/apps/mobile/src/native/composerEditorLayout.ts index 6df36d15ac5e..1e594ce7ae03 100644 --- a/apps/mobile/src/native/composerEditorLayout.ts +++ b/apps/mobile/src/native/composerEditorLayout.ts @@ -1,3 +1,4 @@ +/** Return a finite numeric style length, or undefined for non-numeric values. */ export function numericStyleLength(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } diff --git a/apps/mobile/src/native/useComposerEditorAutoHeight.ts b/apps/mobile/src/native/useComposerEditorAutoHeight.ts index 66f75be56d89..2888c27e78b2 100644 --- a/apps/mobile/src/native/useComposerEditorAutoHeight.ts +++ b/apps/mobile/src/native/useComposerEditorAutoHeight.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { StyleSheet, type StyleProp, type ViewStyle } from "react-native"; import { @@ -9,6 +9,14 @@ import { const CONTENT_HEIGHT_EPSILON = 0.5; +/** Keep the last height when the native sample is a sub-pixel echo. */ +function coalesceContentHeight(current: number | null, nextHeight: number): number { + return current != null && Math.abs(current - nextHeight) < CONTENT_HEIGHT_EPSILON + ? current + : nextHeight; +} + +/** Grow an expanded composer from native content height within min/max style bounds. */ export function useComposerEditorAutoHeight(style: StyleProp | undefined) { const flatStyle = StyleSheet.flatten(style) ?? {}; const minHeight = numericStyleLength(flatStyle.minHeight); @@ -17,18 +25,20 @@ export function useComposerEditorAutoHeight(style: StyleProp | undefi const verticalPadding = verticalPaddingFromViewStyle(flatStyle); const [contentHeight, setContentHeight] = useState(null); - const onContentHeight = useCallback((nextHeight: number) => { + /** Accept a native content-height sample unless it is a sub-pixel echo. */ + function onContentHeight(nextHeight: number) { if (!Number.isFinite(nextHeight) || nextHeight < 0) { return; } - setContentHeight((current) => - current != null && Math.abs(current - nextHeight) < CONTENT_HEIGHT_EPSILON - ? current - : nextHeight, - ); - }, []); - - const laidOutHeight = useMemo(() => { + /** Store a coalesced native content-height sample. */ + function storeCoalescedContentHeight(current: number | null): number { + return coalesceContentHeight(current, nextHeight); + } + setContentHeight(storeCoalescedContentHeight); + } + + /** Concrete Yoga height once the expanded editor has a content measurement. */ + function computeLaidOutHeight() { if (typeof height === "number" || (minHeight === undefined && maxHeight === undefined)) { return undefined; } @@ -41,12 +51,20 @@ export function useComposerEditorAutoHeight(style: StyleProp | undefi maxHeight, verticalPadding, }); - }, [contentHeight, height, maxHeight, minHeight, verticalPadding]); + } + const laidOutHeight = useMemo(computeLaidOutHeight, [ + contentHeight, + height, + maxHeight, + minHeight, + verticalPadding, + ]); - const resolvedStyle = useMemo( - () => (laidOutHeight == null ? style : [style, { height: laidOutHeight }]), - [laidOutHeight, style], - ); + /** Incoming style, plus a measured height when auto-height applies. */ + function computeResolvedStyle() { + return laidOutHeight == null ? style : [style, { height: laidOutHeight }]; + } + const resolvedStyle = useMemo(computeResolvedStyle, [laidOutHeight, style]); return { onContentHeight, resolvedStyle }; } From f287569d6d2c0a28294ed72a3d4408bf2820745f Mon Sep 17 00:00:00 2001 From: macodev00 Date: Sun, 20 Sep 2026 11:05:43 +0000 Subject: [PATCH 3/4] docs(mobile): name remaining Android composer caret-scroll helpers Extract leftover anonymous paste/focus/touch callbacks into named functions with one-line JSDoc/KDoc, and put KDoc above @Suppress so CodeRabbit can attach it. Covers the functions the 69% docstring check still treated as undocumented. Co-authored-by: maco --- .../t3composereditor/T3ComposerEditorView.kt | 75 +++++++++++----- .../src/native/T3ComposerEditor.native.tsx | 85 ++++++++++++------- 2 files changed, 106 insertions(+), 54 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 33799e2f7c5d..2d85ea6f7efe 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -105,7 +105,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( GestureDetector( context, object : GestureDetector.SimpleOnGestureListener() { + /** Consume the down event so a chip tap can fire. */ override fun onDown(event: MotionEvent) = true + /** Press a context/mention/skill chip under the tap. */ override fun onSingleTapUp(event: MotionEvent): Boolean { val offset = editor.getOffsetForPosition(event.x, event.y) val token = @@ -130,28 +132,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } } ) - editor.setOnTouchListener { _, event -> + /** Dispatch chip taps without consuming editor touches. */ + fun onEditorTouch(_view: View, event: MotionEvent): Boolean { contextGestures.onTouchEvent(event) - false - } - editor.pasteTextListener = { text, start, end -> - nativeEventCount += 1 - onComposerPasteText( - mapOf( - "value" to editor.text.toString(), - "eventCount" to nativeEventCount, - "text" to text, - "selection" to currentSelectionPayload(start, end), - ), - ) - } - editor.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - onComposerFocus(emptyMap()) - } else { - onComposerBlur(emptyMap()) - } + return false } + editor.setOnTouchListener(::onEditorTouch) + editor.pasteTextListener = ::onEditorPasteText + editor.setOnFocusChangeListener(::onEditorFocusChanged) editor.addTextChangedListener( object : TextWatcher { /** No-op; text is published after the edit lands. */ @@ -189,8 +177,8 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( ) } - @Suppress("ReturnCount") /** Apply a controlled document and scroll the caret when text or selection changes. */ + @Suppress("ReturnCount") fun setControlledDocumentJson(documentJson: String) { val document = try { JSONObject(documentJson) @@ -264,14 +252,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } } + /** Set the empty-state placeholder shown in the editor. */ fun setPlaceholder(placeholder: String) { editor.placeholder = placeholder } + /** Store the T3 context clipboard fragment used for copy/paste. */ fun setClipboardFragment(fragment: String) { editor.clipboardFragment = fragment } + /** Apply monospace or default typeface from the host font family name. */ fun setFontFamily(fontFamily: String) { editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) { Typeface.MONOSPACE @@ -281,6 +272,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.applyPlaceholder() } + /** Apply text size, then refresh line height, chips, and the placeholder. */ fun setFontSize(fontSize: Float) { editor.textSize = fontSize applyLineHeight() @@ -288,11 +280,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.applyPlaceholder() } + /** Convert the host line height to pixels and apply paint spacing. */ fun setLineHeight(lineHeight: Float) { desiredLineHeightPx = (lineHeight * resources.displayMetrics.density).toInt() applyLineHeight() } + /** Center a single-line composer vertically, or pin multi-line text to the top. */ fun setSingleLineCentered(centered: Boolean) { editor.gravity = if (centered) { Gravity.CENTER_VERTICAL or Gravity.START @@ -301,6 +295,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } } + /** Inset the editor vertically and republish content height. */ fun setContentInsetVertical(contentInsetVertical: Int) { this.contentInsetVertical = max(0, (contentInsetVertical * resources.displayMetrics.density).toInt()) @@ -308,6 +303,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( emitContentSizeIfNeeded() } + /** Enable or disable typing and caret visibility. */ fun setEditable(editable: Boolean) { editor.isEnabled = editable editor.isFocusable = editable @@ -315,51 +311,82 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.isCursorVisible = editable && !editor.readOnly } + /** Block edits while keeping the current text visible. */ fun setReadOnly(readOnly: Boolean) { editor.readOnly = readOnly editor.isCursorVisible = editor.isEnabled && !readOnly } + /** Show or hide the vertical scrollbar without changing caret scrolling. */ fun setScrollEnabled(scrollEnabled: Boolean) { editor.isVerticalScrollBarEnabled = scrollEnabled } + /** Focus the editor after the current layout pass when autoFocus is set. */ fun setAutoFocus(autoFocus: Boolean) { if (autoFocus) { - post { focusEditor() } + post(::focusEditor) } } + /** Apply the autocorrect input flag. */ fun setAutoCorrect(autoCorrect: Boolean) { this.autoCorrect = autoCorrect updateInputFlags() } + /** Apply the spell-check input flag. */ fun setSpellCheck(spellCheck: Boolean) { this.spellCheck = spellCheck updateInputFlags() } + /** Set the byte threshold that intercepts large clipboard pastes. */ fun setTextPasteThresholdBytes(threshold: Int) { editor.textPasteThresholdBytes = threshold } + /** Cap typed and pasted input length at the host-provided character limit. */ fun setMaxInputChars(maxInputChars: Int) { editor.maxInputChars = maxInputChars } + /** Request focus and show the soft keyboard. */ fun focusEditor() { editor.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager imm?.showSoftInput(editor, InputMethodManager.SHOW_IMPLICIT) } + /** Clear focus and hide the soft keyboard. */ fun blurEditor() { editor.clearFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager imm?.hideSoftInputFromWindow(editor.windowToken, 0) } + /** Emit native focus or blur when the editor focus changes. */ + private fun onEditorFocusChanged(_view: View, hasFocus: Boolean) { + if (hasFocus) { + onComposerFocus(emptyMap()) + } else { + onComposerBlur(emptyMap()) + } + } + + /** Publish an intercepted clipboard text paste to JS. */ + private fun onEditorPasteText(text: String, start: Int, end: Int) { + nativeEventCount += 1 + onComposerPasteText( + mapOf( + "value" to editor.text.toString(), + "eventCount" to nativeEventCount, + "text" to text, + "selection" to currentSelectionPayload(start, end), + ), + ) + } + /** Apply an explicit selection from the JS host. */ fun setSelection(start: Int, end: Int) { applySelection(start, end) @@ -391,12 +418,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.inputType = flags } + /** Apply the desired line height as extra paint spacing. */ private fun applyLineHeight() { if (desiredLineHeightPx <= 0) return val fontHeight = editor.paint.fontMetricsInt.descent - editor.paint.fontMetricsInt.ascent editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f) } + /** Tint selection handles and highlight to the theme accent. */ private fun applySelectionTheme(color: Int) { editor.highlightColor = color.withAlpha(0x52) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return @@ -407,11 +436,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.textSelectHandleRight?.mutate()?.setTint(color) } + /** Restore the platform default selection colors. */ private fun resetSelectionTheme() { applySelectionTheme(defaultSelectionColor) editor.highlightColor = defaultHighlightColor } + /** Start/end offsets for a native selection event payload. */ private fun currentSelectionPayload( start: Int = editor.selectionStart, end: Int = editor.selectionEnd diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index af97a65a39eb..3ce2c89c32eb 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -88,12 +88,17 @@ interface NativeComposerEditorProps extends ViewProps { readonly autoFocus: boolean; readonly autoCorrect: boolean; readonly spellCheck: boolean; + /** Native text-change handler. */ readonly onComposerChange: (event: NativeEditorEvent) => void; + /** Native caret-move handler. */ readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; + /** Native image-paste handler. */ readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; + /** Native chip-press handler. */ readonly onComposerContextPress?: ( event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, ) => void; + /** Native T3-context clipboard paste handler. */ readonly onComposerPasteContext?: ( event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, ) => void; @@ -319,6 +324,50 @@ export function ComposerEditor({ setMostRecentEventCount(acknowledgedEventCount); forceNativeEventRender(incrementRenderSequence); } + /** Forward native image-paste URIs to the host. */ + function handleComposerPasteImages(event: NativePasteImagesEvent) { + onPasteImages?.(event.nativeEvent.uris); + } + /** Forward a native chip press to the host. */ + function handleComposerContextPress( + event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, + ) { + props.onContextPress?.(event.nativeEvent); + } + /** Apply a native T3-context paste and notify the host. */ + function handleComposerPasteContext( + event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, + ) { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + props.onPasteContext?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender(incrementRenderSequence); + } + /** Apply a native clipboard text paste and notify the host. */ + function handleComposerPasteText(event: NativePasteTextEvent) { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + // Synchronize the draft before an async paste captures its insertion target. + // React props can still precede the last native keystroke. + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + onPasteText?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender(incrementRenderSequence); + } return ( onPasteImages?.(event.nativeEvent.uris)} - onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} - onComposerPasteContext={(event) => { - const paste = event.nativeEvent; - const acknowledgedEventCount = acceptNativeEvent( - paste.eventCount, - paste.value, - paste.selection, - ); - if (acknowledgedEventCount === false) return; - onChangeText(paste.value); - onSelectionChange?.(paste.selection); - props.onPasteContext?.(paste); - setMostRecentEventCount(acknowledgedEventCount); - forceNativeEventRender((sequence) => sequence + 1); - }} - onComposerPasteText={(event) => { - const paste = event.nativeEvent; - const acknowledgedEventCount = acceptNativeEvent( - paste.eventCount, - paste.value, - paste.selection, - ); - if (acknowledgedEventCount === false) return; - // Synchronize the draft before an async paste captures its insertion target. - // React props can still precede the last native keystroke. - onChangeText(paste.value); - onSelectionChange?.(paste.selection); - onPasteText?.(paste); - setMostRecentEventCount(acknowledgedEventCount); - forceNativeEventRender((sequence) => sequence + 1); - }} + onComposerPasteImages={handleComposerPasteImages} + onComposerContextPress={handleComposerContextPress} + onComposerPasteContext={handleComposerPasteContext} + onComposerPasteText={handleComposerPasteText} onComposerFocus={onFocus} onComposerBlur={onBlur} /> From 4e4a921f39d32640ec676c09108ee09a1d1920ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 06:27:07 +0000 Subject: [PATCH 4/4] fix(mobile): skip Android caret scroll for unchanged selection Controlled document updates can include the current selection without moving it. Scrolling on those resets a manual review scroll. Track whether applySelection changed the range and only bring the caret into view when the text or that range actually changed. Co-authored-by: maco --- .../t3composereditor/T3ComposerEditorView.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 2d85ea6f7efe..41b4a20818ac 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -198,6 +198,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( val previousSelectionEnd = editor.selectionEnd.coerceAtLeast(0) val valueChanged = editor.text.toString() != value + var selectionChanged = false applyingNativeValue = true try { if (valueChanged) { @@ -206,13 +207,15 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( tokensJson = nextTokensJson tokens = nextTokens applyTokenSpans() - if (requestedSelection != null) { + selectionChanged = if (requestedSelection != null) { applySelection( requestedSelection.optInt("start", previousSelectionStart), requestedSelection.optInt("end", previousSelectionEnd), ) } else if (valueChanged) { applySelection(previousSelectionStart, previousSelectionEnd) + } else { + false } } finally { applyingNativeValue = false @@ -220,7 +223,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( emitContentSizeIfNeeded() // Echo re-renders re-apply chip spans without changing text or caret. // Scrolling on those would yank a manual review scroll back to the caret. - if (valueChanged || requestedSelection != null) { + if (valueChanged || selectionChanged) { scrollCaretIntoView() } } @@ -392,16 +395,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( applySelection(start, end) } - /** Set the editor selection and scroll the caret into view. */ - private fun applySelection(start: Int, end: Int) { + /** Set the editor selection and scroll the caret if the normalized range moved. */ + private fun applySelection(start: Int, end: Int): Boolean { val textLength = editor.text?.length ?: 0 val safeStart = start.coerceIn(0, textLength) val safeEnd = end.coerceIn(0, textLength) // Re-applying an unchanged selection resets the keyboard's suggestion // state, so a no-op assignment must be skipped. - if (editor.selectionStart == safeStart && editor.selectionEnd == safeEnd) return + if (editor.selectionStart == safeStart && editor.selectionEnd == safeEnd) return false editor.setSelection(safeStart, safeEnd) scrollCaretIntoView() + return true } /** Apply autocorrect and spell-check flags to the editor input type. */