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..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 @@ -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 @@ -62,6 +63,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) @@ -103,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 = @@ -128,38 +132,27 @@ 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. */ 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() @@ -173,19 +166,18 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( ), ) emitContentSizeIfNeeded() + scrollCaretIntoView() } }, ) - editor.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> - if (right - left != oldRight - oldLeft) applyTokenSpans() - emitContentSizeIfNeeded() - } + editor.addOnLayoutChangeListener(::onEditorLayoutChanged) addView( editor, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), ) } + /** Apply a controlled document and scroll the caret when text or selection changes. */ @Suppress("ReturnCount") fun setControlledDocumentJson(documentJson: String) { val document = try { @@ -206,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) { @@ -214,20 +207,28 @@ 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 } 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 || selectionChanged) { + scrollCaretIntoView() + } } + /** Apply text, placeholder, selection, and chip colors from the theme JSON. */ fun setThemeJson(themeJson: String) { try { val theme = JSONObject(themeJson) @@ -254,14 +255,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 @@ -271,6 +275,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() @@ -278,11 +283,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 @@ -291,6 +298,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()) @@ -298,6 +306,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 @@ -305,65 +314,101 @@ 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) } - 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. */ private fun updateInputFlags() { var flags = InputType.TYPE_CLASS_TEXT or @@ -377,12 +422,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 @@ -393,11 +440,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 @@ -407,6 +456,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 @@ -419,8 +469,45 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( "eventCount" to nativeEventCount, ), ) + 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. + */ + private fun scrollCaretIntoView() { + if (caretScrollPosted) return + caretScrollPosted = true + 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 7f18bae0570a..3ce2c89c32eb 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"; @@ -87,33 +88,48 @@ 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; 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; } 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, @@ -264,8 +280,96 @@ 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); + } + /** 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 ( - + { - 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); - }} - onComposerPasteImages={(event) => 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); - }} + onComposerContentSizeChange={onNativeContentSizeChange} + onComposerChange={handleComposerChange} + onComposerSelectionChange={handleComposerSelectionChange} + onComposerPasteImages={handleComposerPasteImages} + onComposerContextPress={handleComposerContextPress} + onComposerPasteContext={handleComposerPasteContext} + onComposerPasteText={handleComposerPasteText} onComposerFocus={onFocus} onComposerBlur={onBlur} /> diff --git a/apps/mobile/src/native/composerEditorLayout.test.ts b/apps/mobile/src/native/composerEditorLayout.test.ts new file mode 100644 index 000000000000..d847c50bb51e --- /dev/null +++ b/apps/mobile/src/native/composerEditorLayout.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from "vite-plus/test"; + +import { composerEditorLaidOutHeight, verticalPaddingFromViewStyle } from "./composerEditorLayout"; + +/** 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); +} + +/** 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 new file mode 100644 index 000000000000..1e594ce7ae03 --- /dev/null +++ b/apps/mobile/src/native/composerEditorLayout.ts @@ -0,0 +1,42 @@ +/** 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; +} + +/** + * 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..2888c27e78b2 --- /dev/null +++ b/apps/mobile/src/native/useComposerEditorAutoHeight.ts @@ -0,0 +1,70 @@ +import { 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; + +/** 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); + const maxHeight = numericStyleLength(flatStyle.maxHeight); + const height = numericStyleLength(flatStyle.height); + const verticalPadding = verticalPaddingFromViewStyle(flatStyle); + const [contentHeight, setContentHeight] = useState(null); + + /** Accept a native content-height sample unless it is a sub-pixel echo. */ + function onContentHeight(nextHeight: number) { + if (!Number.isFinite(nextHeight) || nextHeight < 0) { + return; + } + /** 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; + } + if (contentHeight == null) { + return undefined; + } + return composerEditorLaidOutHeight({ + contentHeight, + minHeight, + maxHeight, + verticalPadding, + }); + } + const laidOutHeight = useMemo(computeLaidOutHeight, [ + contentHeight, + height, + maxHeight, + minHeight, + verticalPadding, + ]); + + /** 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 }; +}