Skip to content

Commit adce6d9

Browse files
authored
feat: add getCaretRect command and onCaretRectChange event (#234)
* feat: add getCaretRect command and onCaretRectChange event * refactor: rename CaretRect properties for clarity
1 parent 410c957 commit adce6d9

12 files changed

Lines changed: 322 additions & 6 deletions

android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownInputManager.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.facebook.react.viewmanagers.EnrichedMarkdownInputManagerDelegate
1515
import com.facebook.react.viewmanagers.EnrichedMarkdownInputManagerInterface
1616
import com.facebook.yoga.YogaMeasureMode
1717
import com.swmansion.enriched.markdown.input.autolink.LinkRegexConfig
18+
import com.swmansion.enriched.markdown.input.events.OnCaretRectChangeEvent
1819
import com.swmansion.enriched.markdown.input.events.OnChangeMarkdownEvent
1920
import com.swmansion.enriched.markdown.input.events.OnChangeSelectionEvent
2021
import com.swmansion.enriched.markdown.input.events.OnChangeStateEvent
@@ -23,6 +24,7 @@ import com.swmansion.enriched.markdown.input.events.OnContextMenuItemPressEvent
2324
import com.swmansion.enriched.markdown.input.events.OnInputBlurEvent
2425
import com.swmansion.enriched.markdown.input.events.OnInputFocusEvent
2526
import com.swmansion.enriched.markdown.input.events.OnLinkDetectedEvent
27+
import com.swmansion.enriched.markdown.input.events.OnRequestCaretRectResultEvent
2628
import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
2729
import com.swmansion.enriched.markdown.input.layout.InputMeasurementStore
2830
import com.swmansion.enriched.markdown.input.model.StyleType
@@ -83,6 +85,8 @@ class EnrichedMarkdownInputManager :
8385
OnChangeSelectionEvent.EVENT_NAME,
8486
OnChangeStateEvent.EVENT_NAME,
8587
OnRequestMarkdownResultEvent.EVENT_NAME,
88+
OnRequestCaretRectResultEvent.EVENT_NAME,
89+
OnCaretRectChangeEvent.EVENT_NAME,
8690
OnInputFocusEvent.EVENT_NAME,
8791
OnInputBlurEvent.EVENT_NAME,
8892
OnContextMenuItemPressEvent.EVENT_NAME,
@@ -371,6 +375,13 @@ class EnrichedMarkdownInputManager :
371375
view?.eventEmitter?.emitRequestMarkdownResult(requestId)
372376
}
373377

378+
override fun requestCaretRect(
379+
view: EnrichedMarkdownInputView?,
380+
requestId: Int,
381+
) {
382+
view?.eventEmitter?.emitRequestCaretRectResult(requestId)
383+
}
384+
374385
companion object {
375386
const val NAME = "EnrichedMarkdownInput"
376387
}

android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownInputView.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ class EnrichedMarkdownInputView(
221221
forceScrollToSelection()
222222
eventEmitter.emitChangeText()
223223
if (emitMarkdown) eventEmitter.emitChangeMarkdown()
224+
eventEmitter.emitCaretRectChangeIfNeeded()
224225
isTextChanging = false
225226
didTextChangeRecently = true
226227
lastProcessedText = currentText
@@ -247,6 +248,7 @@ class EnrichedMarkdownInputView(
247248

248249
eventEmitter.emitSelection(selStart, selEnd)
249250
eventEmitter.emitState()
251+
eventEmitter.emitCaretRectChangeIfNeeded()
250252
}
251253

252254
private fun applyPendingStyles(
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.swmansion.enriched.markdown.input.events
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.WritableMap
5+
import com.facebook.react.uimanager.events.Event
6+
import com.swmansion.enriched.markdown.input.model.CaretRect
7+
8+
class OnCaretRectChangeEvent(
9+
surfaceId: Int,
10+
viewId: Int,
11+
private val rect: CaretRect,
12+
) : Event<OnCaretRectChangeEvent>(surfaceId, viewId) {
13+
override fun getEventName(): String = EVENT_NAME
14+
15+
override fun getEventData(): WritableMap = Arguments.createMap().also { rect.putInto(it) }
16+
17+
companion object {
18+
const val EVENT_NAME = "onCaretRectChange"
19+
}
20+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.swmansion.enriched.markdown.input.events
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.WritableMap
5+
import com.facebook.react.uimanager.events.Event
6+
import com.swmansion.enriched.markdown.input.model.CaretRect
7+
8+
class OnRequestCaretRectResultEvent(
9+
surfaceId: Int,
10+
viewId: Int,
11+
private val requestId: Int,
12+
private val rect: CaretRect,
13+
) : Event<OnRequestCaretRectResultEvent>(surfaceId, viewId) {
14+
override fun getEventName(): String = EVENT_NAME
15+
16+
override fun getEventData(): WritableMap =
17+
Arguments.createMap().apply {
18+
putInt("requestId", requestId)
19+
rect.putInto(this)
20+
}
21+
22+
companion object {
23+
const val EVENT_NAME = "onRequestCaretRectResult"
24+
}
25+
}

android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.facebook.react.bridge.ReactContext
44
import com.facebook.react.uimanager.UIManagerHelper
55
import com.facebook.react.uimanager.events.Event
66
import com.swmansion.enriched.markdown.input.EnrichedMarkdownInputView
7+
import com.swmansion.enriched.markdown.input.events.OnCaretRectChangeEvent
78
import com.swmansion.enriched.markdown.input.events.OnChangeMarkdownEvent
89
import com.swmansion.enriched.markdown.input.events.OnChangeSelectionEvent
910
import com.swmansion.enriched.markdown.input.events.OnChangeStateEvent
@@ -12,14 +13,17 @@ import com.swmansion.enriched.markdown.input.events.OnContextMenuItemPressEvent
1213
import com.swmansion.enriched.markdown.input.events.OnInputBlurEvent
1314
import com.swmansion.enriched.markdown.input.events.OnInputFocusEvent
1415
import com.swmansion.enriched.markdown.input.events.OnLinkDetectedEvent
16+
import com.swmansion.enriched.markdown.input.events.OnRequestCaretRectResultEvent
1517
import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
1618
import com.swmansion.enriched.markdown.input.formatting.MarkdownSerializer
19+
import com.swmansion.enriched.markdown.input.model.CaretRect
1720
import com.swmansion.enriched.markdown.input.model.StyleType
1821

1922
class InputEventEmitter(
2023
private val view: EnrichedMarkdownInputView,
2124
) {
2225
private var prevState: Map<StyleType, Boolean> = emptyMap()
26+
private var prevCaretRect: CaretRect? = null
2327

2428
fun emitChangeText() {
2529
val plainText = view.text?.toString() ?: ""
@@ -82,6 +86,41 @@ class InputEventEmitter(
8286
dispatch(OnRequestMarkdownResultEvent(surfaceId(), view.id, requestId, serializeToMarkdown()))
8387
}
8488

89+
fun emitRequestCaretRectResult(requestId: Int) {
90+
val rect = computeCaretRect()
91+
dispatch(OnRequestCaretRectResultEvent(surfaceId(), view.id, requestId, rect))
92+
}
93+
94+
fun emitCaretRectChangeIfNeeded() {
95+
val rect = computeCaretRect()
96+
if (rect == prevCaretRect) return
97+
98+
prevCaretRect = rect
99+
dispatch(OnCaretRectChangeEvent(surfaceId(), view.id, rect))
100+
}
101+
102+
private fun computeCaretRect(): CaretRect {
103+
val textLayout = view.layout
104+
val cursorOffset = view.selectionStart
105+
106+
if (textLayout == null || cursorOffset < 0) {
107+
return CaretRect(0f, 0f, 0f, 0f)
108+
}
109+
110+
val line = textLayout.getLineForOffset(cursorOffset)
111+
val rawX = textLayout.getPrimaryHorizontal(cursorOffset) + view.paddingLeft
112+
val rawY = (textLayout.getLineTop(line) + view.paddingTop).toFloat()
113+
val rawBottom = (textLayout.getLineBottom(line) + view.paddingTop).toFloat()
114+
val density = view.resources.displayMetrics.density
115+
116+
return CaretRect(
117+
x = rawX / density,
118+
y = rawY / density,
119+
width = 2f / density,
120+
height = (rawBottom - rawY) / density,
121+
)
122+
}
123+
85124
fun emitContextMenuItemPress(
86125
itemText: String,
87126
selectedText: String,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.swmansion.enriched.markdown.input.model
2+
3+
import com.facebook.react.bridge.WritableMap
4+
5+
data class CaretRect(
6+
val x: Float,
7+
val y: Float,
8+
val width: Float,
9+
val height: Float,
10+
) {
11+
fun putInto(map: WritableMap) {
12+
map.putDouble("x", x.toDouble())
13+
map.putDouble("y", y.toDouble())
14+
map.putDouble("width", width.toDouble())
15+
map.putDouble("height", height.toDouble())
16+
}
17+
}

docs/API_REFERENCE.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,38 @@ interface StyleState {
381381
}
382382
```
383383

384+
### `onCaretRectChange`
385+
386+
Fires when the caret's pixel position changes (typing, selection change, content reflow). The rect is relative to the input's top-left corner, in density-independent pixels. The native side diffs the rect before emitting, so redundant events are suppressed.
387+
388+
| Type | Default Value | Platform |
389+
| --------------------------------- | ------------- | -------- |
390+
| `(rect: CaretRect) => void` | - | Both |
391+
392+
**`CaretRect` shape:**
393+
394+
```ts
395+
interface CaretRect {
396+
x: number;
397+
y: number;
398+
width: number;
399+
height: number;
400+
}
401+
```
402+
403+
All values are in density-independent pixels, relative to the input's top-left corner.
404+
405+
**Example:**
406+
407+
```tsx
408+
<EnrichedMarkdownInput
409+
scrollEnabled={false}
410+
onCaretRectChange={(rect) => {
411+
console.log('Caret at:', rect.x, rect.y);
412+
}}
413+
/>
414+
```
415+
384416
### `onFocus`
385417

386418
Fires when the input gains focus.
@@ -474,6 +506,10 @@ Sets the input content from a Markdown string. Parses the Markdown and applies f
474506

475507
Returns a Promise that resolves with the current Markdown content. The async nature is due to the native bridge — the request is sent to the native side and the result is returned via an event.
476508

509+
### `getCaretRect(): Promise<CaretRect>`
510+
511+
Returns a Promise that resolves with the current caret's pixel position relative to the input. Useful for one-off queries; for continuous tracking, prefer `onCaretRectChange`.
512+
477513
### `setSelection(start: number, end: number)`
478514

479515
Sets the text selection range.

docs/INPUT.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,31 @@ The callback fires only for newly detected links — not for links that were alr
121121

122122
When a manual link is applied (via `setLink` or `insertLink`) over an auto-detected link, the auto-detected link is replaced by the manual one. Auto-link detection skips ranges that already contain a manual link.
123123

124+
## Caret Position Tracking
125+
126+
`EnrichedMarkdownInput` can report the caret's pixel position relative to the input, which is useful when the input is embedded in a scrollable container with `scrollEnabled={false}` and you need to keep the caret visible.
127+
128+
### `onCaretRectChange`
129+
130+
A push-based callback that fires whenever the caret moves (typing, selection change, content reflow). The native side diffs the caret rect before emitting, so redundant events are suppressed automatically.
131+
132+
```tsx
133+
<EnrichedMarkdownInput
134+
scrollEnabled={false}
135+
onCaretRectChange={(rect) => {
136+
console.log(rect);
137+
}}
138+
/>
139+
```
140+
141+
### `getCaretRect()`
142+
143+
An imperative, pull-based method for one-off queries. Returns a Promise that resolves with the current caret rect.
144+
145+
```tsx
146+
const rect = await ref.current?.getCaretRect();
147+
```
148+
124149
## Style Detection
125150

126151
All of the above styles can be detected with the use of [onChangeState](API_REFERENCE.md#onchangestate) callback payload.

ios/input/EnrichedMarkdownInput.mm

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ @implementation EnrichedMarkdownInput {
6767
BOOL bold, italic, underline, strikethrough, spoiler, link, initialized;
6868
} _prevState;
6969

70+
std::optional<CGRect> _prevCaretRect;
71+
7072
#if TARGET_OS_OSX
7173
NSScrollView *_scrollView;
7274
#endif
@@ -761,6 +763,44 @@ - (void)requestMarkdown:(NSInteger)requestId
761763
});
762764
}
763765

766+
- (CGRect)computeCaretRect
767+
{
768+
CGRect caretRect = CGRectZero;
769+
#if !TARGET_OS_OSX
770+
UITextRange *selectedRange = _textView.selectedTextRange;
771+
if (selectedRange != nil) {
772+
caretRect = [_textView caretRectForPosition:selectedRange.start];
773+
}
774+
#else
775+
NSRange selection = _textView.selectedRange;
776+
if (selection.location != NSNotFound) {
777+
NSRange glyphRange = [_textView.layoutManager glyphRangeForCharacterRange:NSMakeRange(selection.location, 0)
778+
actualCharacterRange:NULL];
779+
caretRect = [_textView.layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:_textView.textContainer];
780+
caretRect.origin.x += _textView.textContainerInset.width;
781+
caretRect.origin.y += _textView.textContainerInset.height;
782+
}
783+
#endif
784+
return caretRect;
785+
}
786+
787+
- (void)requestCaretRect:(NSInteger)requestId
788+
{
789+
auto emitter = [self getEventEmitter];
790+
if (emitter == nullptr) {
791+
return;
792+
}
793+
794+
CGRect caretRect = [self computeCaretRect];
795+
emitter->onRequestCaretRectResult({
796+
.requestId = static_cast<int>(requestId),
797+
.x = caretRect.origin.x,
798+
.y = caretRect.origin.y,
799+
.width = caretRect.size.width,
800+
.height = caretRect.size.height,
801+
});
802+
}
803+
764804
- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args
765805
{
766806
RCTEnrichedMarkdownInputHandleCommand(self, commandName, args);
@@ -883,6 +923,29 @@ - (void)emitOnChangeState
883923
});
884924
}
885925

926+
- (void)emitCaretRectChangeIfNeeded
927+
{
928+
auto emitter = [self getEventEmitter];
929+
if (emitter == nullptr) {
930+
return;
931+
}
932+
933+
CGRect caretRect = [self computeCaretRect];
934+
935+
if (_prevCaretRect.has_value() && CGRectEqualToRect(_prevCaretRect.value(), caretRect)) {
936+
return;
937+
}
938+
939+
_prevCaretRect = caretRect;
940+
941+
emitter->onCaretRectChange({
942+
.x = caretRect.origin.x,
943+
.y = caretRect.origin.y,
944+
.width = caretRect.size.width,
945+
.height = caretRect.size.height,
946+
});
947+
}
948+
886949
- (NSArray<NSString *> *)contextMenuItemTexts
887950
{
888951
return _contextMenuItemTexts ?: @[];
@@ -1036,6 +1099,7 @@ - (void)handleTextChanged
10361099
[self emitOnChangeText];
10371100
[self emitOnChangeSelection];
10381101
[self emitFormattingChanged];
1102+
[self emitCaretRectChangeIfNeeded];
10391103
[self requestHeightUpdate];
10401104
[self scheduleRelayoutIfNeeded];
10411105
}
@@ -1139,6 +1203,7 @@ - (void)textViewDidChangeSelection:(UITextView *)textView
11391203

11401204
[self emitOnChangeSelection];
11411205
[self emitOnChangeState];
1206+
[self emitCaretRectChangeIfNeeded];
11421207
}
11431208

11441209
#else
@@ -1209,6 +1274,7 @@ - (void)textInputDidChangeSelection
12091274

12101275
[self emitOnChangeSelection];
12111276
[self emitOnChangeState];
1277+
[self emitCaretRectChangeIfNeeded];
12121278
}
12131279

12141280
// @required stubs for RCTBackedTextInputDelegate — RCTUITextView's internal adapter

0 commit comments

Comments
 (0)