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..21678ade 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,44 @@ 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 + // 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) + 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..b57089be 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]; @@ -1480,6 +1483,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; 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()),