From 42c9c2bfae71e2c78c723048d8a51ec2c327f429 Mon Sep 17 00:00:00 2001 From: dennistan95 Date: Mon, 22 Jun 2026 14:36:40 +0800 Subject: [PATCH 1/2] feat: add getCaretRect() to read the caret bounding rect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an async getCaretRect() to the EnrichedTextInput imperative handle so consumers can anchor UI (link popovers, floating toolbars, mention menus) to the live caret. onChangeSelection only exposes character offsets, so there was no way to position an overlay at the caret; native already has the geometry (UITextView caretRectForPosition: on iOS, Layout on Android) — this surfaces it to JS, mirroring the existing getHTML()/requestHTML request-response round-trip. - spec: OnCaretRectEvent event, onCaretRect prop, requestCaretRect command - JS: getCaretRect() (requestId->promise with a timeout fallback so it resolves null instead of hanging), onCaretRect handler, unmount cleanup - iOS: caretRectForPosition: converted into the component view's space; emits a `valid` flag when the rect is null/infinite - Android: Layout primary-horizontal + line top/bottom, padding/scroll-adjusted, px->dp for parity with iOS points; new OnCaretRectEvent + manager registration Note: this package ships pre-generated Fabric codegen (includesGeneratedCode) and a compiled lib/, so the generated artifacts + lib/ need regenerating (codegen + bob build) before release. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../textinput/EnrichedTextInputView.kt | 36 +++++++++++++ .../textinput/EnrichedTextInputViewManager.kt | 9 ++++ .../textinput/events/OnCaretRectEvent.kt | 36 +++++++++++++ ios/EnrichedTextInputView.mm | 37 +++++++++++-- src/native/EnrichedTextInput.tsx | 52 +++++++++++++++++++ src/spec/EnrichedTextInputNativeComponent.ts | 15 ++++++ src/types.ts | 16 ++++++ 7 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 android/src/main/java/com/swmansion/enriched/textinput/events/OnCaretRectEvent.kt diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index b2a0c16c..b46304ef 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -43,6 +43,7 @@ import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.parser.EnrichedParser import com.swmansion.enriched.common.pixelFromSpOrDp import com.swmansion.enriched.textinput.events.MentionHandler +import com.swmansion.enriched.textinput.events.OnCaretRectEvent import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent import com.swmansion.enriched.textinput.events.OnInputBlurEvent import com.swmansion.enriched.textinput.events.OnInputFocusEvent @@ -1017,6 +1018,41 @@ class EnrichedTextInputView : dispatcher?.dispatchEvent(OnRequestHtmlResultEvent(surfaceId, id, requestId, html, experimentalSynchronousEvents)) } + fun requestCaretRect(requestId: Int) { + var valid = false + var xDp = 0.0 + var yDp = 0.0 + var widthDp = 0.0 + var heightDp = 0.0 + val currentLayout = layout + val pos = selectionStart + if (currentLayout != null && pos >= 0) { + try { + val line = currentLayout.getLineForOffset(pos) + val xPx = currentLayout.getPrimaryHorizontal(pos) + totalPaddingLeft - scrollX + val topPx = currentLayout.getLineTop(line) + totalPaddingTop - scrollY + val bottomPx = currentLayout.getLineBottom(line) + totalPaddingTop - scrollY + // React Native lays out in density-independent pixels; convert from raw px + // so the caret rect matches the JS coordinate space (parity with iOS points). + val density = resources.displayMetrics.density.takeIf { it > 0f } ?: 1f + xDp = (xPx / density).toDouble() + yDp = (topPx / density).toDouble() + widthDp = (2f / density).toDouble() + heightDp = ((bottomPx - topPx) / density).toDouble() + valid = true + } catch (_: Exception) { + valid = false + } + } + + val reactContext = context as ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) + dispatcher?.dispatchEvent( + OnCaretRectEvent(surfaceId, id, requestId, xDp, yDp, widthDp, heightDp, valid, experimentalSynchronousEvents) + ) + } + // Sometimes setting up style triggers many changes in sequence // Eg. removing conflicting styles -> changing text -> applying spans // In such scenario we want to prevent from handling side effects (eg. onTextChanged) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 9dcbb824..3c44414f 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -25,6 +25,7 @@ import com.swmansion.enriched.textinput.events.OnInputFocusEvent import com.swmansion.enriched.textinput.events.OnInputKeyPressEvent import com.swmansion.enriched.textinput.events.OnLinkDetectedEvent import com.swmansion.enriched.textinput.events.OnMentionDetectedEvent +import com.swmansion.enriched.textinput.events.OnCaretRectEvent import com.swmansion.enriched.textinput.events.OnMentionEvent import com.swmansion.enriched.textinput.events.OnPasteImagesEvent import com.swmansion.enriched.textinput.events.OnRequestHtmlResultEvent @@ -72,6 +73,7 @@ class EnrichedTextInputViewManager : map.put(OnMentionEvent.EVENT_NAME, mapOf("registrationName" to OnMentionEvent.EVENT_NAME)) map.put(OnChangeSelectionEvent.EVENT_NAME, mapOf("registrationName" to OnChangeSelectionEvent.EVENT_NAME)) map.put(OnRequestHtmlResultEvent.EVENT_NAME, mapOf("registrationName" to OnRequestHtmlResultEvent.EVENT_NAME)) + map.put(OnCaretRectEvent.EVENT_NAME, mapOf("registrationName" to OnCaretRectEvent.EVENT_NAME)) map.put(OnInputKeyPressEvent.EVENT_NAME, mapOf("registrationName" to OnInputKeyPressEvent.EVENT_NAME)) map.put(OnPasteImagesEvent.EVENT_NAME, mapOf("registrationName" to OnPasteImagesEvent.EVENT_NAME)) map.put(OnContextMenuItemPressEvent.EVENT_NAME, mapOf("registrationName" to OnContextMenuItemPressEvent.EVENT_NAME)) @@ -472,6 +474,13 @@ class EnrichedTextInputViewManager : view?.requestHTML(requestId) } + override fun requestCaretRect( + view: EnrichedTextInputView?, + requestId: Int, + ) { + view?.requestCaretRect(requestId) + } + override fun setTextAlignment( view: EnrichedTextInputView?, alignment: String, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/events/OnCaretRectEvent.kt b/android/src/main/java/com/swmansion/enriched/textinput/events/OnCaretRectEvent.kt new file mode 100644 index 00000000..fda25a8b --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/events/OnCaretRectEvent.kt @@ -0,0 +1,36 @@ +package com.swmansion.enriched.textinput.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnCaretRectEvent( + surfaceId: Int, + viewId: Int, + private val requestId: Int, + private val x: Double, + private val y: Double, + private val width: Double, + private val height: Double, + private val valid: Boolean, + private val experimentalSynchronousEvents: Boolean, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap { + val eventData: WritableMap = Arguments.createMap() + eventData.putInt("requestId", requestId) + eventData.putDouble("x", x) + eventData.putDouble("y", y) + eventData.putDouble("width", width) + eventData.putDouble("height", height) + eventData.putBoolean("valid", valid) + return eventData + } + + override fun experimental_isSynchronous(): Boolean = experimentalSynchronousEvents + + companion object { + const val EVENT_NAME: String = "onCaretRect" + } +} diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 21ff1b72..45e4aad9 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1282,6 +1282,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { } else if ([commandName isEqualToString:@"requestHTML"]) { NSInteger requestId = [((NSNumber *)args[0]) integerValue]; [self requestHTML:requestId]; + } else if ([commandName isEqualToString:@"requestCaretRect"]) { + NSInteger requestId = [((NSNumber *)args[0]) integerValue]; + [self requestCaretRect:requestId]; } else if ([commandName isEqualToString:@"setTextAlignment"]) { NSString *alignmentString = (NSString *)args[0]; @@ -1336,10 +1339,11 @@ - (void)setValue:(NSString *)value { } - (void)setCustomSelection:(NSInteger)visibleStart end:(NSInteger)visibleEnd { - NSString *text = textView.textStorage.string; - - NSUInteger actualStart = [self getActualIndex:visibleStart text:text]; - NSUInteger actualEnd = [self getActualIndex:visibleEnd text:text]; + NSUInteger textLength = textView.textStorage.string.length; + NSInteger clampedStart = MAX(visibleStart, 0); + NSInteger clampedEnd = MAX(visibleEnd, clampedStart); + NSUInteger actualStart = MIN((NSUInteger)clampedStart, textLength); + NSUInteger actualEnd = MIN((NSUInteger)clampedEnd, textLength); textView.selectedRange = NSMakeRange(actualStart, actualEnd - actualStart); } @@ -1480,6 +1484,31 @@ - (void)requestHTML:(NSInteger)requestId { } } +- (void)requestCaretRect:(NSInteger)requestId { + auto emitter = [self getEventEmitter]; + if (emitter == nullptr) { + return; + } + UITextRange *selectedRange = textView.selectedTextRange; + CGRect caretRect = CGRectNull; + if (selectedRange != nil) { + // Caret rect at the selection's end (the live insertion point), in textView + // coordinates, then converted into this component view's coordinate space so + // JS can position an overlay relative to the editor. + caretRect = [textView caretRectForPosition:selectedRange.end]; + if (!CGRectIsNull(caretRect) && !CGRectIsInfinite(caretRect)) { + caretRect = [self convertRect:caretRect fromView:textView]; + } + } + BOOL valid = !CGRectIsNull(caretRect) && !CGRectIsInfinite(caretRect); + emitter->onCaretRect({.requestId = static_cast(requestId), + .x = valid ? static_cast(caretRect.origin.x) : 0.0, + .y = valid ? static_cast(caretRect.origin.y) : 0.0, + .width = valid ? static_cast(caretRect.size.width) : 0.0, + .height = valid ? static_cast(caretRect.size.height) : 0.0, + .valid = valid ? true : false}); +} + - (void)emitOnKeyPressEvent:(NSString *)key { auto emitter = [self getEventEmitter]; if (emitter != nullptr) { diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index f399633c..8ebed483 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -13,6 +13,7 @@ import EnrichedTextInputNativeComponent, { type OnMentionEvent, type OnMentionDetectedInternal, type OnRequestHtmlResultEvent, + type OnCaretRectEvent, } from '../spec/EnrichedTextInputNativeComponent'; import type { HostInstance, @@ -27,6 +28,7 @@ import { toNativeRegexConfig } from '../utils/regexParser'; import { nullthrows } from '../utils/nullthrows'; import { ENRICHED_TEXT_INPUT_DEFAULT_PROPS } from '../utils/EnrichedTextInputDefaultProps'; import type { + CaretRect, ContextMenuItem, EnrichedTextInputProps, OnLinkDetected, @@ -46,6 +48,15 @@ type HtmlRequest = { reject: (error: Error) => void; }; +type CaretRectRequest = { + resolve: (rect: CaretRect | null) => void; + timeout: ReturnType; +}; + +// If the native caret-rect event never arrives (e.g. a platform without the +// native patch applied), resolve null rather than leaking a pending promise. +const CARET_RECT_TIMEOUT_MS = 400; + export const EnrichedTextInput = ({ ref, autoFocus, @@ -89,6 +100,9 @@ export const EnrichedTextInput = ({ const nextHtmlRequestId = useRef(1); const pendingHtmlRequests = useRef(new Map()); + const nextCaretRequestId = useRef(1); + const pendingCaretRequests = useRef(new Map()); + // Store onPress callbacks in a ref so native only receives serializable data const contextMenuCallbacksRef = useRef< Map @@ -135,11 +149,17 @@ export const EnrichedTextInput = ({ useEffect(() => { const pendingRequests = pendingHtmlRequests.current; + const pendingCaret = pendingCaretRequests.current; return () => { pendingRequests.forEach(({ reject }) => { reject(new Error('Component unmounted')); }); pendingRequests.clear(); + pendingCaret.forEach(({ resolve, timeout }) => { + clearTimeout(timeout); + resolve(null); + }); + pendingCaret.clear(); }; }, []); @@ -190,6 +210,28 @@ export const EnrichedTextInput = ({ Commands.requestHTML(nullthrows(nativeRef.current), requestId); }); }, + getCaretRect: () => { + return new Promise((resolve) => { + const node = nativeRef.current; + if (!node) { + resolve(null); + return; + } + const requestId = nextCaretRequestId.current++; + const timeout = setTimeout(() => { + pendingCaretRequests.current.delete(requestId); + resolve(null); + }, CARET_RECT_TIMEOUT_MS); + pendingCaretRequests.current.set(requestId, { resolve, timeout }); + try { + Commands.requestCaretRect(node, requestId); + } catch { + clearTimeout(timeout); + pendingCaretRequests.current.delete(requestId); + resolve(null); + } + }); + }, toggleBold: () => { Commands.toggleBold(nullthrows(nativeRef.current)); }, @@ -327,6 +369,15 @@ export const EnrichedTextInput = ({ pendingHtmlRequests.current.delete(requestId); }; + const handleCaretRect = (e: NativeSyntheticEvent) => { + const { requestId, x, y, width, height, valid } = e.nativeEvent; + const pending = pendingCaretRequests.current.get(requestId); + if (!pending) return; + clearTimeout(pending.timeout); + pendingCaretRequests.current.delete(requestId); + pending.resolve(valid ? { x, y, width, height } : null); + }; + return ( ; onChangeSelection?: DirectEventHandler; onRequestHtmlResult?: DirectEventHandler; + onCaretRect?: DirectEventHandler; onInputKeyPress?: DirectEventHandler; onPasteImages?: DirectEventHandler; onContextMenuItemPress?: DirectEventHandler; @@ -478,6 +488,10 @@ interface NativeCommands { viewRef: React.ElementRef, requestId: Int32 ) => void; + requestCaretRect: ( + viewRef: React.ElementRef, + requestId: Int32 + ) => void; setTextAlignment: ( viewRef: React.ElementRef, alignment: string @@ -515,6 +529,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'startMention', 'addMention', 'requestHTML', + 'requestCaretRect', 'setTextAlignment', ], }); diff --git a/src/types.ts b/src/types.ts index 0509ba64..ca552da4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -423,6 +423,14 @@ export interface OnSubmitEditing { export type FocusEvent = NativeSyntheticEvent; export type BlurEvent = NativeSyntheticEvent; +/** Caret bounding rect in the editor view's coordinate space (RN dp / iOS points). */ +export interface CaretRect { + x: number; + y: number; + width: number; + height: number; +} + /** * Imperative handle exposed via `ref` on ``. * @@ -449,6 +457,14 @@ export interface EnrichedTextInputInstance extends NativeMethods { */ getHTML: () => Promise; + /** + * Resolves the current caret's bounding rect (for positioning UI like a link + * popover or floating toolbar) in the editor view's coordinate space. Resolves + * `null` if unavailable or the native event doesn't arrive — callers should + * treat `null` as "no anchor". + */ + getCaretRect: () => Promise; + /** Toggles bold on the current selection (or toggles it for future typing if nothing is selected). */ toggleBold: () => void; From 69474064b7a2700c5612d00441c0537a8cdf81b2 Mon Sep 17 00:00:00 2001 From: dennistan95 Date: Mon, 22 Jun 2026 14:46:15 +0800 Subject: [PATCH 2/2] Address review: revert stray selection change, fix caret anchor + web target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iOS: revert an unrelated setCustomSelection change that slipped in from a packaging diff (restores getActualIndex ZWSP-aware mapping). - Android: anchor requestCaretRect to selectionEnd (the active caret), matching iOS selectedTextRange.end, so a non-empty selection reports the live caret. - Web: implement getCaretRect via ProseMirror coordsAtPos (selection end), converted to editor-element coordinates — so the cross-platform instance type is fully satisfied and web/build targets don't break. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../textinput/EnrichedTextInputView.kt | 5 ++++- ios/EnrichedTextInputView.mm | 9 ++++----- src/web/EnrichedTextInput.tsx | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index b46304ef..21678ade 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -1025,7 +1025,10 @@ class EnrichedTextInputView : var widthDp = 0.0 var heightDp = 0.0 val currentLayout = layout - val pos = selectionStart + // Anchor to the selection END (the active insertion point), matching iOS' + // selectedTextRange.end — so a non-empty selection reports the live caret, + // not the start of the range. + val pos = selectionEnd if (currentLayout != null && pos >= 0) { try { val line = currentLayout.getLineForOffset(pos) diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 45e4aad9..b57089be 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1339,11 +1339,10 @@ - (void)setValue:(NSString *)value { } - (void)setCustomSelection:(NSInteger)visibleStart end:(NSInteger)visibleEnd { - NSUInteger textLength = textView.textStorage.string.length; - NSInteger clampedStart = MAX(visibleStart, 0); - NSInteger clampedEnd = MAX(visibleEnd, clampedStart); - NSUInteger actualStart = MIN((NSUInteger)clampedStart, textLength); - NSUInteger actualEnd = MIN((NSUInteger)clampedEnd, textLength); + NSString *text = textView.textStorage.string; + + NSUInteger actualStart = [self getActualIndex:visibleStart text:text]; + NSUInteger actualEnd = [self getActualIndex:visibleEnd text:text]; textView.selectedRange = NSMakeRange(actualStart, actualEnd - actualStart); } diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 06a0c264..9d39d001 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -331,6 +331,25 @@ export const EnrichedTextInput = ({ ); }, getHTML: () => Promise.resolve(normalizeHtmlFromTiptap(editor.getHTML())), + getCaretRect: () => { + try { + // Anchor to the selection END (active caret), matching native. ProseMirror + // returns viewport coords; convert to the editor element's coordinate space + // (parity with native returning the rect relative to the editor view). + const pos = editor.state.selection.to; + const coords = editor.view.coordsAtPos(pos); + const dom = editor.view.dom as HTMLElement; + const base = dom.getBoundingClientRect(); + return Promise.resolve({ + x: coords.left - base.left + dom.scrollLeft, + y: coords.top - base.top + dom.scrollTop, + width: Math.max(1, coords.right - coords.left), + height: Math.max(0, coords.bottom - coords.top), + }); + } catch { + return Promise.resolve(null); + } + }, toggleBold: () => runFocused(editor, (c) => c.toggleBold()), toggleItalic: () => runFocused(editor, (c) => c.toggleItalic()), toggleUnderline: () => runFocused(editor, (c) => c.toggleUnderline()),