diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt index a52be906d..67f36879b 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt @@ -38,36 +38,37 @@ import com.swmansion.enriched.markdown.utils.input.MarkdownStyleParser @ReactModule(name = EnrichedMarkdownTextInputManager.NAME) class EnrichedMarkdownTextInputManager : - SimpleViewManager(), - EnrichedMarkdownTextInputManagerInterface { - private val delegate: ViewManagerDelegate = + SimpleViewManager(), + EnrichedMarkdownTextInputManagerInterface { + private val delegate: ViewManagerDelegate = EnrichedMarkdownTextInputManagerDelegate(this) - override fun getDelegate(): ViewManagerDelegate = delegate + override fun getDelegate(): ViewManagerDelegate = delegate override fun getName(): String = NAME - override fun createViewInstance(reactContext: ThemedReactContext): EnrichedMarkdownTextInputView = - EnrichedMarkdownTextInputView(reactContext) + override fun createViewInstance(reactContext: ThemedReactContext): EnrichedMarkdownTextInputScrollView = + EnrichedMarkdownTextInputScrollView(reactContext) override fun updateState( - view: EnrichedMarkdownTextInputView, + view: EnrichedMarkdownTextInputScrollView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?, ): Any? { - view.stateWrapper = stateWrapper + // Route Fabric state to the editor so its auto-grow loop still drives the re-measure. + view.input.stateWrapper = stateWrapper return super.updateState(view, props, stateWrapper) } - override fun onAfterUpdateTransaction(view: EnrichedMarkdownTextInputView) { + override fun onAfterUpdateTransaction(view: EnrichedMarkdownTextInputScrollView) { super.onAfterUpdateTransaction(view) - view.afterUpdateTransaction() + view.input.afterUpdateTransaction() } - override fun onDropViewInstance(view: EnrichedMarkdownTextInputView) { - view.dismissActiveMention() + override fun onDropViewInstance(view: EnrichedMarkdownTextInputScrollView) { + view.input.dismissActiveMention() super.onDropViewInstance(view) - view.layoutManager.release() + view.input.layoutManager.release() } override fun measure( @@ -81,6 +82,7 @@ class EnrichedMarkdownTextInputManager : heightMode: YogaMeasureMode?, attachmentsPositions: FloatArray?, ): Long { + // The editor's content is measured by the container's tag (the editor shares it via setId). val id = localData?.getInt("viewTag") return InputMeasurementStore.getMeasureById(context, id, width, height, heightMode, props) } @@ -107,171 +109,184 @@ class EnrichedMarkdownTextInputManager : @ReactProp(name = "defaultValue") override fun setDefaultValue( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: String?, ) { - if (value != null && view?.text?.isEmpty() == true) { - view.setValueFromJS(value) + val input = view?.input ?: return + if (value != null && input.text?.isEmpty() == true) { + input.setValueFromJS(value) } } @ReactProp(name = "placeholder") override fun setPlaceholder( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: String?, ) { - view?.hint = value + view?.input?.hint = value } @ReactProp(name = "placeholderTextColor", customType = "Color") override fun setPlaceholderTextColor( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Int?, ) { - view?.setHintTextColor(value ?: Color.GRAY) + view?.input?.setHintTextColor(value ?: Color.GRAY) } @ReactProp(name = "editable", defaultBoolean = true) override fun setEditable( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Boolean, ) { - view?.isEnabled = value + view?.input?.isEnabled = value } @ReactProp(name = "autoFocus", defaultBoolean = false) override fun setAutoFocus( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Boolean, ) { - view?.autoFocusRequested = value + view?.input?.autoFocusRequested = value } @ReactProp(name = "scrollEnabled", defaultBoolean = true) override fun setScrollEnabled( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Boolean, ) { - view?.scrollEnabled = value + // The editor never scrolls itself; the container does. Gate the container's scrolling and its + // scrollbar so scrollEnabled={false} truly disables scrolling (parity with iOS). + view?.scrollingEnabled = value view?.isVerticalScrollBarEnabled = value } + @ReactProp(name = "contentInset") + override fun setContentInset( + view: EnrichedMarkdownTextInputScrollView?, + value: ReadableMap?, + ) { + view?.setContentInsetFromProps(value) + } + @ReactProp(name = "autoCapitalize") override fun setAutoCapitalize( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: String?, ) { - view?.setAutoCapitalize(value) + view?.input?.setAutoCapitalize(value) } @ReactProp(name = "multiline", defaultBoolean = true) override fun setMultiline( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Boolean, ) { - view?.isSingleLine = !value + view?.input?.isSingleLine = !value } @ReactProp(name = "cursorColor", customType = "Color") override fun setCursorColor( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Int?, ) { - view?.setCursorColorFromProps(value) + view?.input?.setCursorColorFromProps(value) } @ReactProp(name = "selectionColor", customType = "Color") override fun setSelectionColor( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Int?, ) { if (value != null) { - view?.highlightColor = value + view?.input?.highlightColor = value } } @ReactProp(name = "markdownStyle") override fun setMarkdownStyle( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: ReadableMap?, ) { - if (view == null || value == null) return + val input = view?.input ?: return + if (value == null) return val style = MarkdownStyleParser.parse(value) - view.setAutoLinkStyle(style) - val changed = view.formatter.updateStyle(style) + input.setAutoLinkStyle(style) + val changed = input.formatter.updateStyle(style) if (changed) { - view.applyFormatting() + input.applyFormatting() } } @ReactProp(name = "color", customType = "Color") override fun setColor( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Int?, ) { - view?.setColorFromProps(value) + view?.input?.setColorFromProps(value) } @ReactProp(name = "fontSize", defaultFloat = 16f) override fun setFontSize( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Float, ) { - view?.setFontSizeFromProps(value) + view?.input?.setFontSizeFromProps(value) } @ReactProp(name = "lineHeight", defaultFloat = 0f) override fun setLineHeight( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Float, ) { - if (value > 0 && view != null) { - view.setLineSpacing(value - view.textSize, 1f) + val input = view?.input ?: return + if (value > 0) { + input.setLineSpacing(value - input.textSize, 1f) } } @ReactProp(name = "fontFamily") override fun setFontFamily( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: String?, ) { - view?.setFontFamily(value) + view?.input?.setFontFamily(value) } @ReactProp(name = "fontWeight") override fun setFontWeight( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: String?, ) { - view?.setFontWeight(value) + view?.input?.setFontWeight(value) } @ReactProp(name = "isOnChangeMarkdownSet", defaultBoolean = false) override fun setIsOnChangeMarkdownSet( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: Boolean, ) { - view?.emitMarkdown = value + view?.input?.emitMarkdown = value } @ReactProp(name = "contextMenuItems") override fun setContextMenuItems( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: ReadableArray?, ) { - if (view == null) return + val input = view?.input ?: return val items = (0 until (value?.size() ?: 0)).mapNotNull { value?.getMap(it)?.getString("text") } - view.setContextMenuItems(items) + input.setContextMenuItems(items) } @ReactProp(name = "selectionMenuConfig") override fun setSelectionMenuConfig( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: ReadableMap?, ) { - if (view == null) return - view.contextMenu.selectionMenuConfig = + val input = view?.input ?: return + input.contextMenu.selectionMenuConfig = if (value == null) { InputSelectionMenuConfig() } else { @@ -284,11 +299,11 @@ class EnrichedMarkdownTextInputManager : @ReactProp(name = "formatMenuConfig") override fun setFormatMenuConfig( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: ReadableMap?, ) { - if (view == null) return - view.contextMenu.formatMenuConfig = + val input = view?.input ?: return + input.contextMenu.formatMenuConfig = if (value == null) { FormatMenuConfig() } else { @@ -305,10 +320,10 @@ class EnrichedMarkdownTextInputManager : @ReactProp(name = "linkRegex") override fun setLinkRegex( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: ReadableMap?, ) { - if (view == null) return + val input = view?.input ?: return val config = if (value != null) { LinkRegexConfig( @@ -321,24 +336,24 @@ class EnrichedMarkdownTextInputManager : } else { LinkRegexConfig("", caseInsensitive = false, dotAll = false, isDisabled = false, isDefault = true) } - view.setLinkRegex(config) + input.setLinkRegex(config) } @ReactProp(name = "mentionIndicators") override fun setMentionIndicators( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: ReadableArray?, ) { val indicators = (0 until (value?.size() ?: 0)) .mapNotNull { value?.getString(it) } .filter { it.isNotEmpty() } - view?.setMentionIndicators(indicators) + view?.input?.setMentionIndicators(indicators) } @ReactProp(name = "writingDirection") override fun setWritingDirection( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, value: String?, ) { // No-op on Android — EditText resolves direction per paragraph via @@ -346,128 +361,132 @@ class EnrichedMarkdownTextInputManager : } override fun updateProperties( - view: EnrichedMarkdownTextInputView, + view: EnrichedMarkdownTextInputScrollView, props: ReactStylesDiffMap, ) { + // Apply border/background to the container (the visible frame), not the scrolling editor. BorderPropsApplicator.apply(view, props) super.updateProperties(view, props) } override fun setPadding( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, left: Int, top: Int, right: Int, bottom: Int, ) { super.setPadding(view, left, top, right, bottom) - view?.setPadding(left, top, right, bottom) + // Route React `padding` through the same path as contentInset so the auto-grow measure accounts + // for it (plain setPadding would leave the measured height short by the vertical padding). + view?.input?.setReactPadding(left, top, right, bottom) } // Commands - override fun focus(view: EnrichedMarkdownTextInputView?) { - view?.requestFocusProgrammatically() + override fun focus(view: EnrichedMarkdownTextInputScrollView?) { + view?.input?.requestFocusProgrammatically() } - override fun blur(view: EnrichedMarkdownTextInputView?) { - view?.clearFocus() + override fun blur(view: EnrichedMarkdownTextInputScrollView?) { + view?.input?.clearFocus() } override fun setValue( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, markdown: String?, ) { if (markdown != null) { - view?.setValueFromJS(markdown) + view?.input?.setValueFromJS(markdown) } } override fun setSelection( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, start: Int, end: Int, ) { - val length = view?.text?.length ?: 0 + val input = view?.input ?: return + val length = input.text?.length ?: 0 val clampedStart = start.coerceIn(0, length) val clampedEnd = end.coerceIn(0, length) - view?.setSelection(clampedStart, clampedEnd) + input.setSelection(clampedStart, clampedEnd) } - override fun toggleBold(view: EnrichedMarkdownTextInputView?) { - view?.toggleInlineStyle(StyleType.BOLD) + override fun toggleBold(view: EnrichedMarkdownTextInputScrollView?) { + view?.input?.toggleInlineStyle(StyleType.BOLD) } - override fun toggleItalic(view: EnrichedMarkdownTextInputView?) { - view?.toggleInlineStyle(StyleType.ITALIC) + override fun toggleItalic(view: EnrichedMarkdownTextInputScrollView?) { + view?.input?.toggleInlineStyle(StyleType.ITALIC) } - override fun toggleUnderline(view: EnrichedMarkdownTextInputView?) { - view?.toggleInlineStyle(StyleType.UNDERLINE) + override fun toggleUnderline(view: EnrichedMarkdownTextInputScrollView?) { + view?.input?.toggleInlineStyle(StyleType.UNDERLINE) } - override fun toggleStrikethrough(view: EnrichedMarkdownTextInputView?) { - view?.toggleInlineStyle(StyleType.STRIKETHROUGH) + override fun toggleStrikethrough(view: EnrichedMarkdownTextInputScrollView?) { + view?.input?.toggleInlineStyle(StyleType.STRIKETHROUGH) } - override fun toggleSpoiler(view: EnrichedMarkdownTextInputView?) { - view?.toggleInlineStyle(StyleType.SPOILER) + override fun toggleSpoiler(view: EnrichedMarkdownTextInputScrollView?) { + view?.input?.toggleInlineStyle(StyleType.SPOILER) } override fun setLink( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, url: String?, ) { if (url != null) { - view?.setLinkForSelection(url) + view?.input?.setLinkForSelection(url) } } override fun insertLink( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, text: String?, url: String?, ) { if (url != null) { - view?.insertLinkAtCursor(text ?: url, url) + view?.input?.insertLinkAtCursor(text ?: url, url) } } override fun insertMention( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, displayText: String?, url: String?, ) { if (displayText != null && url != null) { - view?.insertMention(displayText, url) + view?.input?.insertMention(displayText, url) } } override fun startMention( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, indicator: String?, ) { if (indicator != null) { - view?.startMention(indicator) + view?.input?.startMention(indicator) } } - override fun removeLink(view: EnrichedMarkdownTextInputView?) { - view?.removeLinkAtCursor() + override fun removeLink(view: EnrichedMarkdownTextInputScrollView?) { + view?.input?.removeLinkAtCursor() } override fun requestMarkdown( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, requestId: Int, ) { - view?.eventEmitter?.emitRequestMarkdownResult(requestId) + view?.input?.eventEmitter?.emitRequestMarkdownResult(requestId) } override fun requestCaretRect( - view: EnrichedMarkdownTextInputView?, + view: EnrichedMarkdownTextInputScrollView?, requestId: Int, ) { - view?.eventEmitter?.emitRequestCaretRectResult(requestId) + view?.input?.eventEmitter?.emitRequestCaretRectResult(requestId) } companion object { diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputScrollView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputScrollView.kt new file mode 100644 index 000000000..2f2b723e0 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputScrollView.kt @@ -0,0 +1,103 @@ +package com.swmansion.enriched.markdown.input + +import android.content.Context +import android.graphics.Outline +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.View.MeasureSpec +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import androidx.core.widget.NestedScrollView +import com.facebook.react.bridge.ReadableMap + +// Wraps the editor in a scroll container so contentInset behaves like iOS textContainerInset: the +// cushion is part of the scrolled content instead of clipping inside a scrolling EditText. +class EnrichedMarkdownTextInputScrollView( + context: Context, +) : NestedScrollView(context) { + val input = EnrichedMarkdownTextInputView(context) + + // scrollEnabled prop, for parity with iOS where false disables scrolling. + var scrollingEnabled = true + + // RN swallows the editor's requestLayout and Fabric won't re-lay-out a fixed-height container, so + // re-measure ourselves to update the scroll range when the editor grows. + private val measureAndLayout = + Runnable { + if (width == 0 || height == 0) return@Runnable + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + ) + layout(left, top, right, bottom) + } + + init { + isFillViewport = true + isVerticalScrollBarEnabled = true + isVerticalFadingEdgeEnabled = false + // RN parents default to overflow:visible and don't clip us, so clip the scrolled editor to the + // viewport ourselves (rounded via the background outline, else a plain rect). + outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline( + view: View, + outline: Outline, + ) { + val background = view.background + if (background != null) background.getOutline(outline) else outline.setRect(0, 0, view.width, view.height) + } + } + clipToOutline = true + input.scrollEnabled = false + input.gravity = Gravity.TOP or Gravity.START + addView( + input, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } + + // Mirror the React tag onto the editor so its events and tag-keyed measure resolve to this component. + override fun setId(id: Int) { + super.setId(id) + input.id = id + } + + override fun onSizeChanged( + w: Int, + h: Int, + oldw: Int, + oldh: Int, + ) { + super.onSizeChanged(w, h, oldw, oldh) + invalidateOutline() + // After autoFocus the keyboard resize finalizes the viewport; re-reveal the caret until the user touches. + if (input.pendingCaretScroll) post { input.scrollContainerToCaret() } + } + + override fun requestLayout() { + super.requestLayout() + // Only the scrolling case needs this; auto-grow is re-laid-out by Fabric. Coalesced per frame. + if (!scrollingEnabled) return + removeCallbacks(measureAndLayout) + post(measureAndLayout) + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = scrollingEnabled && super.onInterceptTouchEvent(ev) + + override fun onTouchEvent(ev: MotionEvent): Boolean = scrollingEnabled && super.onTouchEvent(ev) + + override fun onDetachedFromWindow() { + removeCallbacks(measureAndLayout) + super.onDetachedFromWindow() + } + + fun setContentInsetFromProps(value: ReadableMap?) { + input.setContentInsetFromProps(value) + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt index dacdfd16d..80a8de4d3 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.BlendMode import android.graphics.BlendModeColorFilter import android.graphics.Color +import android.graphics.Rect import android.os.Build import android.text.Editable import android.text.InputType @@ -16,6 +17,7 @@ import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import androidx.appcompat.widget.AppCompatEditText +import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.ReactConstants import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.PixelUtil @@ -67,10 +69,19 @@ class EnrichedMarkdownTextInputView( var emitMarkdown = false var autoFocusRequested = false + + // Set when autoFocus places the caret at the end; tells the container to scroll the caret into view + // as the viewport settles (content measure, keyboard resize), until the user takes over by touching. + var pendingCaretScroll = false var stateWrapper: StateWrapper? = null val layoutManager = InputLayoutManager(this) private var pendingAutoFocusKeyboard = false + // Internal padding mirroring iOS textContainerInset, in px (left, top, right, bottom). The measure + // adds it back into the auto-grow height so the text keeps the cushion without clipping. + var contentInsetPx = Rect() + private set + private var typefaceDirty = false private var fontFamilyValue: String? = null private var fontWeightValue: Int = ReactConstants.UNSET @@ -107,7 +118,8 @@ class EnrichedMarkdownTextInputView( private fun prepareComponent() { isSingleLine = false isHorizontalScrollBarEnabled = false - isVerticalScrollBarEnabled = true + // The editor never scrolls itself; the container owns the scrollbar. + isVerticalScrollBarEnabled = false inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or @@ -136,6 +148,30 @@ class EnrichedMarkdownTextInputView( } } + // iOS uses UITextView.textContainerInset; on Android it's editor padding. The editor doesn't scroll + // (the container does), so the cushion is scrolled content and never clips. + fun setContentInsetFromProps(value: ReadableMap?) { + fun px(key: String): Int = if (value?.hasKey(key) == true) PixelUtil.toPixelFromDIP(value.getDouble(key).toFloat()).toInt() else 0 + applyEditorInset(Rect(px("left"), px("top"), px("right"), px("bottom"))) + } + + // React `padding` style, applied like contentInset so the auto-grow measure accounts for it. + // contentInset and padding write the same editor padding, so setting both is last-writer-wins. + fun setReactPadding( + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + applyEditorInset(Rect(left, top, right, bottom)) + } + + private fun applyEditorInset(inset: Rect) { + contentInsetPx = inset + setPadding(inset.left, inset.top, inset.right, inset.bottom) + layoutManager.invalidateLayout() + } + override fun onAttachedToWindow() { super.onAttachedToWindow() runAsATransaction { super.setTextIsSelectable(true) } @@ -178,6 +214,8 @@ class EnrichedMarkdownTextInputView( when (ev.action) { MotionEvent.ACTION_DOWN -> { detectScrollMovement = true + // The user takes over scrolling/positioning; stop the autoFocus caret-reveal. + pendingCaretScroll = false parent?.requestDisallowInterceptTouchEvent(true) } @@ -256,7 +294,6 @@ class EnrichedMarkdownTextInputView( detectorPipeline.processTextChange(editable, currentText, editStart, insertedLength) } - forceScrollToSelection() eventEmitter.emitChangeText() if (emitMarkdown) eventEmitter.emitChangeMarkdown() updateActiveMention() @@ -327,35 +364,20 @@ class EnrichedMarkdownTextInputView( private fun applyFormattingAndEmit() { applyFormatting() - forceScrollToSelection() if (emitMarkdown) eventEmitter.emitChangeMarkdown() eventEmitter.emitState() } - private fun forceScrollToSelection() { + // The editor never scrolls itself; ask the scroll container to bring the caret line into view. Used + // after autoFocus places the caret at the end so a long pre-filled value reveals the end, not the top. + fun scrollContainerToCaret() { val textLayout = layout ?: return - val cursorOffset = selectionStart - if (cursorOffset <= 0) return - - val selectedLineIndex = textLayout.getLineForOffset(cursorOffset) - val selectedLineTop = textLayout.getLineTop(selectedLineIndex) - val selectedLineBottom = textLayout.getLineBottom(selectedLineIndex) - val visibleTextHeight = height - paddingTop - paddingBottom - if (visibleTextHeight <= 0) return - - val visibleTop = scrollY - val visibleBottom = scrollY + visibleTextHeight - var targetScrollY = scrollY - - if (selectedLineTop < visibleTop) { - targetScrollY = selectedLineTop - } else if (selectedLineBottom > visibleBottom) { - targetScrollY = selectedLineBottom - visibleTextHeight - } - - val maxScrollY = (textLayout.height - visibleTextHeight).coerceAtLeast(0) - targetScrollY = targetScrollY.coerceIn(0, maxScrollY) - scrollTo(scrollX, targetScrollY) + val line = textLayout.getLineForOffset(selectionStart.coerceAtLeast(0)) + val top = paddingTop + textLayout.getLineTop(line) + // Include the bottom inset so revealing the end shows the cushion below the last line (clamped to + // the scroll range, so a caret on a middle line just keeps a little breathing room). + val bottom = paddingTop + textLayout.getLineBottom(line) + paddingBottom + requestRectangleOnScreen(Rect(0, top, width, bottom), true) } fun toggleInlineStyle(styleType: StyleType) { @@ -572,7 +594,6 @@ class EnrichedMarkdownTextInputView( setSelection(text?.length ?: 0) } applyFormatting() - forceScrollToSelection() layoutManager.invalidateLayout() lastProcessedText = text?.toString() ?: "" } finally { @@ -667,6 +688,10 @@ class EnrichedMarkdownTextInputView( requestFocus() setSelection(text?.length ?: 0) showAutoFocusKeyboardIfPending() + // Reveal the caret (now at the end) once the container has measured the content; onSizeChanged + // re-runs it as the keyboard resizes the viewport, until the user touches to take over. + pendingCaretScroll = true + post { scrollContainerToCaret() } } } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt index 4e96cb736..f7dbb046a 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt @@ -14,7 +14,7 @@ class InputLayoutManager( val text = view.text val paint = view.paint - val needUpdate = InputMeasurementStore.store(view.id, text, paint) + val needUpdate = InputMeasurementStore.store(view.id, text, paint, view.contentInsetPx) if (!needUpdate) return val state = Arguments.createMap() diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt index d7fc9ac55..94421f829 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt @@ -1,6 +1,7 @@ package com.swmansion.enriched.markdown.input.layout import android.content.Context +import android.graphics.Rect import android.os.Build import android.text.StaticLayout import android.text.TextPaint @@ -23,6 +24,7 @@ object InputMeasurementStore { val cachedSize: Long, val text: CharSequence?, val paintParams: PaintParams, + val inset: Rect, ) private val data = ConcurrentHashMap() @@ -31,14 +33,15 @@ object InputMeasurementStore { id: Int, text: CharSequence?, paint: TextPaint, + inset: Rect, ): Boolean { val cachedWidth = data[id]?.cachedWidth ?: 0f val cachedSize = data[id]?.cachedSize ?: 0L - val size = measure(cachedWidth, text, paint) + val size = measure(cachedWidth, text, paint, inset) val paintParams = PaintParams(paint.typeface, paint.textSize) - data[id] = MeasurementParams(cachedWidth, size, text, paintParams) + data[id] = MeasurementParams(cachedWidth, size, text, paintParams, inset) return size != cachedSize } @@ -55,6 +58,10 @@ object InputMeasurementStore { props: ReadableMap?, ): Long { val size = getMeasureByIdInternal(context, id, width, props) + // EXACTLY = fixed frame: fill it and let the container scroll the editor, not collapse to content. + if (heightMode === YogaMeasureMode.EXACTLY) { + return YogaMeasureOutput.make(YogaMeasureOutput.getWidth(size), PixelUtil.toDIPFromPixel(height)) + } if (heightMode !== YogaMeasureMode.AT_MOST) { return size } @@ -84,8 +91,8 @@ object InputMeasurementStore { textSize = value.paintParams.fontSize } - val size = measure(width, value.text, paint) - data[id] = MeasurementParams(width, size, value.text, value.paintParams) + val size = measure(width, value.text, paint, value.inset) + data[id] = MeasurementParams(width, size, value.text, value.paintParams, value.inset) return size } @@ -110,16 +117,26 @@ object InputMeasurementStore { isAntiAlias = true } - return measure(width, text, paint) + return measure(width, text, paint, insetFromProps(props)) + } + + private fun insetFromProps(props: ReadableMap?): Rect { + val inset = props?.getMap("contentInset") ?: return Rect() + + fun px(key: String): Int = if (inset.hasKey(key)) PixelUtil.toPixelFromDIP(inset.getDouble(key).toFloat()).toInt() else 0 + return Rect(px("left"), px("top"), px("right"), px("bottom")) } private fun measure( maxWidth: Float, text: CharSequence?, paint: TextPaint, + inset: Rect, ): Long { val content = text ?: "" - val widthPx = maxWidth.toInt().coerceAtLeast(0) + // The text wraps inside the horizontal inset and the vertical inset is added back below, so the + // reported height matches the padded EditText exactly and nothing clips. + val widthPx = (maxWidth.toInt() - inset.left - inset.right).coerceAtLeast(0) val builder = StaticLayout.Builder @@ -136,7 +153,7 @@ object InputMeasurementStore { } val staticLayout = builder.build() - val heightInDip = PixelUtil.toDIPFromPixel(staticLayout.height.toFloat()) + val heightInDip = PixelUtil.toDIPFromPixel((staticLayout.height + inset.top + inset.bottom).toFloat()) val widthInDip = PixelUtil.toDIPFromPixel(maxWidth) return YogaMeasureOutput.make(widthInDip, heightInDip) } diff --git a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextInputShadowNode.h b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextInputShadowNode.h index 22cabef61..d1853b59f 100644 --- a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextInputShadowNode.h +++ b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextInputShadowNode.h @@ -23,6 +23,9 @@ class MarkdownTextInputShadowNode final } static ShadowNodeTraits BaseTraits() { + // Kept as an auto-grow measuring leaf so consumers without an explicit height still grow to + // content (measureContent measures the child editor via the synced tag); with a definite/EXACTLY + // height the measure fills instead, and the NestedScrollView scrolls the editor inside. auto traits = ConcreteViewShadowNode::BaseTraits(); traits.set(ShadowNodeTraits::Trait::LeafYogaNode); traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); diff --git a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h index 3cdb2f0f6..0e66a9075 100644 --- a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +++ b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h @@ -37,6 +37,7 @@ inline folly::dynamic toDynamic(const EnrichedMarkdownTextInputProps &props) { serializedProps["fontWeight"] = props.fontWeight; serializedProps["fontFamily"] = props.fontFamily; serializedProps["lineHeight"] = props.lineHeight; + serializedProps["contentInset"] = toDynamic(props.contentInset); return serializedProps; } diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm b/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm index ce0004fe3..4f6be4367 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm +++ b/packages/react-native-enriched-markdown/ios/input/ENRMInputTextView.mm @@ -74,11 +74,24 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender - (void)layoutSubviews { [super layoutSubviews]; + // Re-arming the relayout on every layout pass feeds back into the scroll; a scrolling text view + // lays out natively, so only the auto-grow path needs it. + if (self.scrollEnabled) { + return; + } if (self.markdownTextInput != nil) { [self.markdownTextInput scheduleRelayoutIfNeeded]; } } +// Caret auto-scroll goes through here; grow the target rect by the bottom inset so the caret keeps +// that cushion above the keyboard instead of gluing flush to it. +- (void)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated +{ + rect.size.height += self.textContainerInset.bottom; + [super scrollRectToVisible:rect animated:animated]; +} + @end #else // TARGET_OS_OSX diff --git a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm index 442cfa45f..61b827a05 100644 --- a/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm +++ b/packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm @@ -301,6 +301,20 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _textView.scrollEnabled = newViewProps.scrollEnabled; } +#if !TARGET_OS_OSX + if (newViewProps.contentInset.top != oldViewProps.contentInset.top || + newViewProps.contentInset.bottom != oldViewProps.contentInset.bottom || + newViewProps.contentInset.left != oldViewProps.contentInset.left || + newViewProps.contentInset.right != oldViewProps.contentInset.right) { + // textContainerInset, not scroll contentInset: the cushion must survive while typing, which an + // overscroll inset wouldn't (the caret auto-scroll never reaches it). + _textView.textContainerInset = UIEdgeInsetsMake(newViewProps.contentInset.top, + newViewProps.contentInset.left, + newViewProps.contentInset.bottom, + newViewProps.contentInset.right); + } +#endif + if (newViewProps.autoCapitalize != oldViewProps.autoCapitalize) { NSString *value = [NSString stringWithUTF8String:newViewProps.autoCapitalize.c_str()]; _textView.autocapitalizationType = ENRMAutocapitalizationTypeFromString(value); @@ -440,6 +454,12 @@ - (void)_performRelayout if (textLength == 0) { return; } + // A scrolling text view lays out and sizes itself natively; forcing a whole-text relayout a + // runloop after the caret auto-scroll shifts the content under the scrolled view and stutters. + // Only the auto-grow (non-scrolling) path needs the manual relayout and content size below. + if (self->_textView.scrollEnabled) { + return; + } NSRange wholeRange = NSMakeRange(0, textLength); NSRange actualRange = NSMakeRange(0, 0); [self->_textView.layoutManager invalidateLayoutForCharacterRange:wholeRange actualCharacterRange:&actualRange]; diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx index 33126514c..a2f972a5b 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx @@ -8,6 +8,7 @@ import { import type React from 'react'; import EnrichedMarkdownTextInputNativeComponent, { Commands, + type ContentInset, type NativeProps, type OnChangeTextEvent, type OnChangeMarkdownEvent, @@ -153,6 +154,7 @@ export interface EnrichedMarkdownTextInputProps extends Omit< editable?: boolean; autoFocus?: boolean; scrollEnabled?: boolean; + contentInset?: ContentInset; autoCapitalize?: string; multiline?: boolean; cursorColor?: ColorValue; @@ -232,6 +234,7 @@ export const EnrichedMarkdownTextInput = ({ editable = true, autoFocus = false, scrollEnabled = true, + contentInset, autoCapitalize = 'sentences', multiline = true, cursorColor, @@ -505,6 +508,7 @@ export const EnrichedMarkdownTextInput = ({ editable={editable} autoFocus={autoFocus} scrollEnabled={scrollEnabled} + contentInset={contentInset} autoCapitalize={autoCapitalize} multiline={multiline} cursorColor={cursorColor} diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts index 282e5238f..20ca6f078 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInputNativeComponent.ts @@ -142,6 +142,13 @@ export interface OnContextMenuItemPressEvent { }; } +export interface ContentInset { + top: CodegenTypes.Float; + bottom: CodegenTypes.Float; + left: CodegenTypes.Float; + right: CodegenTypes.Float; +} + export interface NativeProps extends ViewProps { /** * Initial markdown content. @@ -170,6 +177,13 @@ export interface NativeProps extends ViewProps { * @default true */ scrollEnabled?: boolean; + /** + * Vertical content padding for the editor (iOS and Android). `top` pushes the first line down (e.g. + * below a translucent header); `bottom` keeps room past the last line that survives while typing. It + * is part of the scrollable content and scrolls away with the text: on iOS via + * UITextView.textContainerInset, on Android via the editor's padding inside the scroll container. + */ + contentInset?: Readonly; /** * Auto-capitalization behavior. */