Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OnCaretRectEvent>(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"
}
}
28 changes: 28 additions & 0 deletions ios/EnrichedTextInputView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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<int>(requestId),
.x = valid ? static_cast<double>(caretRect.origin.x) : 0.0,
.y = valid ? static_cast<double>(caretRect.origin.y) : 0.0,
.width = valid ? static_cast<double>(caretRect.size.width) : 0.0,
.height = valid ? static_cast<double>(caretRect.size.height) : 0.0,
.valid = valid ? true : false});
}

- (void)emitOnKeyPressEvent:(NSString *)key {
auto emitter = [self getEventEmitter];
if (emitter != nullptr) {
Expand Down
52 changes: 52 additions & 0 deletions src/native/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import EnrichedTextInputNativeComponent, {
type OnMentionEvent,
type OnMentionDetectedInternal,
type OnRequestHtmlResultEvent,
type OnCaretRectEvent,
} from '../spec/EnrichedTextInputNativeComponent';
import type {
HostInstance,
Expand All @@ -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,
Expand All @@ -46,6 +48,15 @@ type HtmlRequest = {
reject: (error: Error) => void;
};

type CaretRectRequest = {
resolve: (rect: CaretRect | null) => void;
timeout: ReturnType<typeof setTimeout>;
};

// 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,
Expand Down Expand Up @@ -89,6 +100,9 @@ export const EnrichedTextInput = ({
const nextHtmlRequestId = useRef(1);
const pendingHtmlRequests = useRef(new Map<number, HtmlRequest>());

const nextCaretRequestId = useRef(1);
const pendingCaretRequests = useRef(new Map<number, CaretRectRequest>());

// Store onPress callbacks in a ref so native only receives serializable data
const contextMenuCallbacksRef = useRef<
Map<string, ContextMenuItem['onPress']>
Expand Down Expand Up @@ -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();
};
}, []);

Expand Down Expand Up @@ -190,6 +210,28 @@ export const EnrichedTextInput = ({
Commands.requestHTML(nullthrows(nativeRef.current), requestId);
});
},
getCaretRect: () => {
return new Promise<CaretRect | null>((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));
},
Expand Down Expand Up @@ -327,6 +369,15 @@ export const EnrichedTextInput = ({
pendingHtmlRequests.current.delete(requestId);
};

const handleCaretRect = (e: NativeSyntheticEvent<OnCaretRectEvent>) => {
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 (
<EnrichedTextInputNativeComponent
ref={nativeRef}
Expand Down Expand Up @@ -354,6 +405,7 @@ export const EnrichedTextInput = ({
onMention={handleMentionEvent}
onChangeSelection={onChangeSelection}
onRequestHtmlResult={handleRequestHtmlResult}
onCaretRect={handleCaretRect}
onInputKeyPress={onKeyPress}
contextMenuItems={nativeContextMenuItems}
textShortcuts={textShortcuts}
Expand Down
15 changes: 15 additions & 0 deletions src/spec/EnrichedTextInputNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ export interface OnRequestHtmlResultEvent {
html: UnsafeMixed;
}

export interface OnCaretRectEvent {
requestId: Int32;
x: Float;
y: Float;
width: Float;
height: Float;
valid: boolean;
}

export interface OnSubmitEditing {
text: string;
}
Expand Down Expand Up @@ -389,6 +398,7 @@ export interface NativeProps extends ViewProps {
onMention?: DirectEventHandler<OnMentionEvent>;
onChangeSelection?: DirectEventHandler<OnChangeSelectionEvent>;
onRequestHtmlResult?: DirectEventHandler<OnRequestHtmlResultEvent>;
onCaretRect?: DirectEventHandler<OnCaretRectEvent>;
onInputKeyPress?: DirectEventHandler<OnKeyPressEvent>;
onPasteImages?: DirectEventHandler<OnPasteImagesEvent>;
onContextMenuItemPress?: DirectEventHandler<OnContextMenuItemPressEvent>;
Expand Down Expand Up @@ -478,6 +488,10 @@ interface NativeCommands {
viewRef: React.ElementRef<ComponentType>,
requestId: Int32
) => void;
requestCaretRect: (
viewRef: React.ElementRef<ComponentType>,
requestId: Int32
) => void;
setTextAlignment: (
viewRef: React.ElementRef<ComponentType>,
alignment: string
Expand Down Expand Up @@ -515,6 +529,7 @@ export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
'startMention',
'addMention',
'requestHTML',
'requestCaretRect',
'setTextAlignment',
],
});
Expand Down
16 changes: 16 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,14 @@ export interface OnSubmitEditing {
export type FocusEvent = NativeSyntheticEvent<TargetedEvent>;
export type BlurEvent = NativeSyntheticEvent<TargetedEvent>;

/** 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 `<EnrichedTextInput />`.
*
Expand All @@ -449,6 +457,14 @@ export interface EnrichedTextInputInstance extends NativeMethods {
*/
getHTML: () => Promise<string>;

/**
* 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<CaretRect | null>;
Comment on lines +460 to +466

/** Toggles bold on the current selection (or toggles it for future typing if nothing is selected). */
toggleBold: () => void;

Expand Down
19 changes: 19 additions & 0 deletions src/web/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down