@apollohg/react-native-prose-editor 0.5.10 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -551,6 +551,18 @@ class EditorEditText @JvmOverloads constructor(
551
551
  }
552
552
  }
553
553
 
554
+ internal fun caretRect(): RectF? {
555
+ val textLayout = layout ?: return null
556
+ val selectionOffset = selectionEnd.takeIf { it >= 0 } ?: return null
557
+ val clampedOffset = selectionOffset.coerceIn(0, textLayout.text.length)
558
+ val line = textLayout.getLineForOffset(clampedOffset)
559
+ val caretLeft = textLayout.getPrimaryHorizontal(clampedOffset)
560
+ val left = totalPaddingLeft + caretLeft - scrollX
561
+ val top = totalPaddingTop + textLayout.getLineTop(line) - scrollY
562
+ val bottom = totalPaddingTop + textLayout.getLineBottom(line) - scrollY
563
+ return RectF(left, top.toFloat(), left + 1f, bottom.toFloat())
564
+ }
565
+
554
566
  // ── Input Handling: Text Commit ─────────────────────────────────────
555
567
 
556
568
  /**
@@ -5,6 +5,7 @@ import android.content.Context
5
5
  import android.content.ContextWrapper
6
6
  import android.graphics.Rect
7
7
  import android.graphics.RectF
8
+ import android.os.SystemClock
8
9
  import android.view.Gravity
9
10
  import android.view.MotionEvent
10
11
  import android.view.View
@@ -61,7 +62,8 @@ class NativeEditorExpoView(
61
62
  private var lastEmittedContentHeight = 0
62
63
  private var outsideTapWindowCallback: Window.Callback? = null
63
64
  private var previousWindowCallback: Window.Callback? = null
64
- private var toolbarFrameInWindow: RectF? = null
65
+ private var toolbarFramesInWindow: List<RectF> = emptyList()
66
+ private var lastToolbarTouchUptimeMs: Long? = null
65
67
  private var addons = NativeEditorAddons(null)
66
68
  private var mentionQueryState: MentionQueryState? = null
67
69
  private var lastMentionEventJson: String? = null
@@ -91,7 +93,7 @@ class NativeEditorExpoView(
91
93
  ViewCompat.setOnApplyWindowInsetsListener(keyboardToolbarView) { _, insets ->
92
94
  currentImeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
93
95
  updateKeyboardToolbarLayout()
94
- updateKeyboardToolbarVisibility()
96
+ updateAttachedKeyboardToolbarForInsets()
95
97
  insets
96
98
  }
97
99
 
@@ -101,6 +103,12 @@ class NativeEditorExpoView(
101
103
  installOutsideTapBlurHandlerIfNeeded()
102
104
  refreshMentionQuery()
103
105
  } else {
106
+ if (shouldPreserveFocusAfterToolbarTouch()) {
107
+ richTextView.editorEditText.post {
108
+ focus()
109
+ }
110
+ return@setOnFocusChangeListener
111
+ }
104
112
  uninstallOutsideTapBlurHandler()
105
113
  clearMentionQueryState()
106
114
  }
@@ -195,23 +203,52 @@ class NativeEditorExpoView(
195
203
  if (lastToolbarFrameJson == toolbarFrameJson) return
196
204
  lastToolbarFrameJson = toolbarFrameJson
197
205
  if (toolbarFrameJson.isNullOrBlank()) {
198
- toolbarFrameInWindow = null
206
+ toolbarFramesInWindow = emptyList()
199
207
  return
200
208
  }
201
209
 
202
- toolbarFrameInWindow = try {
210
+ toolbarFramesInWindow = try {
203
211
  val json = JSONObject(toolbarFrameJson)
204
- RectF(
205
- json.optDouble("x").toFloat(),
206
- json.optDouble("y").toFloat(),
207
- (json.optDouble("x") + json.optDouble("width")).toFloat(),
208
- (json.optDouble("y") + json.optDouble("height")).toFloat()
209
- )
212
+ val frames = json.optJSONArray("frames")
213
+ if (frames != null) {
214
+ buildList {
215
+ for (index in 0 until frames.length()) {
216
+ frames.optJSONObject(index)?.toToolbarFrame()?.let { add(it) }
217
+ }
218
+ }
219
+ } else {
220
+ listOfNotNull(json.toToolbarFrame())
221
+ }
210
222
  } catch (_: Throwable) {
211
- null
223
+ emptyList()
212
224
  }
213
225
  }
214
226
 
227
+ private fun JSONObject.toToolbarFrame(): RectF? {
228
+ val x = optDouble("x", Double.NaN)
229
+ val y = optDouble("y", Double.NaN)
230
+ val width = optDouble("width", Double.NaN)
231
+ val height = optDouble("height", Double.NaN)
232
+ if (
233
+ x.isNaN() || x.isInfinite() ||
234
+ y.isNaN() || y.isInfinite() ||
235
+ width.isNaN() || width.isInfinite() ||
236
+ height.isNaN() || height.isInfinite()
237
+ ) {
238
+ return null
239
+ }
240
+ if (width <= 0.0 || height <= 0.0) {
241
+ return null
242
+ }
243
+
244
+ return RectF(
245
+ x.toFloat(),
246
+ y.toFloat(),
247
+ (x + width).toFloat(),
248
+ (y + height).toFloat()
249
+ )
250
+ }
251
+
215
252
  fun setPendingEditorUpdateJson(editorUpdateJson: String?) {
216
253
  pendingEditorUpdateJson = editorUpdateJson
217
254
  }
@@ -230,14 +267,33 @@ class NativeEditorExpoView(
230
267
 
231
268
  fun focus() {
232
269
  richTextView.editorEditText.requestFocus()
270
+ richTextView.editorEditText.post {
271
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
272
+ imm?.showSoftInput(richTextView.editorEditText, InputMethodManager.SHOW_IMPLICIT)
273
+ }
233
274
  }
234
275
 
235
276
  fun blur() {
277
+ clearRecentToolbarTouch()
236
278
  richTextView.editorEditText.clearFocus()
237
279
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
238
280
  imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
239
281
  }
240
282
 
283
+ fun getCaretRectJson(): String? {
284
+ if (width <= 0 || height <= 0) return null
285
+ val rect = richTextView.caretRect() ?: return null
286
+ val density = resources.displayMetrics.density
287
+ return JSONObject()
288
+ .put("x", rect.left / density)
289
+ .put("y", rect.top / density)
290
+ .put("width", rect.width() / density)
291
+ .put("height", rect.height() / density)
292
+ .put("editorWidth", width / density)
293
+ .put("editorHeight", height / density)
294
+ .toString()
295
+ }
296
+
241
297
  override fun onDetachedFromWindow() {
242
298
  super.onDetachedFromWindow()
243
299
  uninstallOutsideTapBlurHandler()
@@ -404,24 +460,85 @@ class NativeEditorExpoView(
404
460
  if (isTouchInsideKeyboardToolbar(event)) {
405
461
  return false
406
462
  }
407
- val toolbarFrame = toolbarFrameInWindow
408
- if (toolbarFrame != null) {
409
- // toolbarFrame is in DP (from React Native's measureInWindow),
410
- // but rawX/rawY are in pixels — convert before comparing.
411
- val density = resources.displayMetrics.density
412
- val frameInPx = RectF(
463
+ if (isTouchInsideStandaloneToolbar(event)) {
464
+ markRecentToolbarTouch()
465
+ return false
466
+ }
467
+ val rect = Rect()
468
+ richTextView.editorEditText.getGlobalVisibleRect(rect)
469
+ return !rect.contains(event.rawX.toInt(), event.rawY.toInt())
470
+ }
471
+
472
+ private fun markRecentToolbarTouch() {
473
+ lastToolbarTouchUptimeMs = SystemClock.uptimeMillis()
474
+ }
475
+
476
+ private fun clearRecentToolbarTouch() {
477
+ lastToolbarTouchUptimeMs = null
478
+ }
479
+
480
+ private fun shouldPreserveFocusAfterToolbarTouch(): Boolean {
481
+ val lastToolbarTouch = lastToolbarTouchUptimeMs ?: return false
482
+ val elapsedMs = SystemClock.uptimeMillis() - lastToolbarTouch
483
+ return elapsedMs in 0L..TOOLBAR_FOCUS_PRESERVE_MS
484
+ }
485
+
486
+ internal fun markRecentToolbarTouchForTesting() {
487
+ markRecentToolbarTouch()
488
+ }
489
+
490
+ internal fun shouldPreserveFocusAfterToolbarTouchForTesting(): Boolean =
491
+ shouldPreserveFocusAfterToolbarTouch()
492
+
493
+ private fun isTouchInsideStandaloneToolbar(event: MotionEvent): Boolean {
494
+ val visibleWindowFrame = Rect()
495
+ getWindowVisibleDisplayFrame(visibleWindowFrame)
496
+ return isPointInsideStandaloneToolbar(event.rawX, event.rawY, visibleWindowFrame)
497
+ }
498
+
499
+ internal fun isPointInsideStandaloneToolbarForTesting(
500
+ rawX: Float,
501
+ rawY: Float,
502
+ visibleWindowFrame: Rect
503
+ ): Boolean = isPointInsideStandaloneToolbar(rawX, rawY, visibleWindowFrame)
504
+
505
+ private fun isPointInsideStandaloneToolbar(
506
+ rawX: Float,
507
+ rawY: Float,
508
+ visibleWindowFrame: Rect
509
+ ): Boolean {
510
+ if (toolbarFramesInWindow.isEmpty()) {
511
+ return false
512
+ }
513
+ // toolbarFrame is in DP from React Native's measureInWindow. On Android
514
+ // that is window-relative after visible-window insets are subtracted,
515
+ // while rawX/rawY are screen pixels. Fabric/newer implementations may
516
+ // differ here, so accept both window-relative and raw-screen comparisons.
517
+ val density = resources.displayMetrics.density
518
+ val hitSlopPx = TOOLBAR_HIT_SLOP_DP * density
519
+ val eventX = rawX - visibleWindowFrame.left
520
+ val eventY = rawY - visibleWindowFrame.top
521
+ for (toolbarFrame in toolbarFramesInWindow) {
522
+ val windowFrameInPx = RectF(
413
523
  toolbarFrame.left * density,
414
524
  toolbarFrame.top * density,
415
525
  toolbarFrame.right * density,
416
526
  toolbarFrame.bottom * density
417
- )
418
- if (frameInPx.contains(event.rawX, event.rawY)) {
419
- return false
527
+ ).apply {
528
+ inset(-hitSlopPx, -hitSlopPx)
529
+ }
530
+ val screenFrameInPx = RectF(windowFrameInPx).apply {
531
+ offset(visibleWindowFrame.left.toFloat(), visibleWindowFrame.top.toFloat())
532
+ }
533
+ if (
534
+ windowFrameInPx.contains(rawX, rawY) ||
535
+ windowFrameInPx.contains(eventX, eventY) ||
536
+ screenFrameInPx.contains(rawX, rawY)
537
+ ) {
538
+ return true
420
539
  }
421
540
  }
422
- val rect = Rect()
423
- richTextView.editorEditText.getGlobalVisibleRect(rect)
424
- return !rect.contains(event.rawX.toInt(), event.rawY.toInt())
541
+ return false
425
542
  }
426
543
 
427
544
  private fun isTouchInsideKeyboardToolbar(event: MotionEvent): Boolean {
@@ -433,6 +550,11 @@ class NativeEditorExpoView(
433
550
  return rect.contains(event.rawX.toInt(), event.rawY.toInt())
434
551
  }
435
552
 
553
+ private companion object {
554
+ private const val TOOLBAR_HIT_SLOP_DP = 8f
555
+ private const val TOOLBAR_FOCUS_PRESERVE_MS = 750L
556
+ }
557
+
436
558
  private fun resolveActivity(context: Context): Activity? {
437
559
  var current: Context? = context
438
560
  while (current is ContextWrapper) {
@@ -658,6 +780,11 @@ class NativeEditorExpoView(
658
780
  keyboardToolbarView.layoutParams = params
659
781
  }
660
782
 
783
+ private fun updateAttachedKeyboardToolbarForInsets() {
784
+ keyboardToolbarView.visibility = if (currentImeBottom > 0) View.VISIBLE else View.INVISIBLE
785
+ updateEditorViewportInset()
786
+ }
787
+
661
788
  private fun updateKeyboardToolbarVisibility() {
662
789
  val shouldAttach =
663
790
  showsToolbar &&
@@ -380,6 +380,10 @@ class NativeEditorModule : Module() {
380
380
  view.blur()
381
381
  }
382
382
 
383
+ AsyncFunction("getCaretRect") { view: NativeEditorExpoView ->
384
+ view.getCaretRectJson()
385
+ }
386
+
383
387
  AsyncFunction("applyEditorUpdate") { view: NativeEditorExpoView, updateJson: String ->
384
388
  view.applyEditorUpdate(updateJson)
385
389
  }
@@ -11,6 +11,7 @@ import android.view.View
11
11
  import android.view.ViewOutlineProvider
12
12
  import android.widget.HorizontalScrollView
13
13
  import android.widget.LinearLayout
14
+ import androidx.appcompat.R as AppCompatR
14
15
  import androidx.appcompat.widget.AppCompatButton
15
16
  import androidx.appcompat.widget.PopupMenu
16
17
  import androidx.appcompat.widget.AppCompatTextView
@@ -834,7 +835,7 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
834
835
  0.38f
835
836
  )
836
837
  active -> theme?.buttonActiveColor ?: resolveColorAttr(
837
- MaterialR.attr.colorPrimary,
838
+ AppCompatR.attr.colorPrimary,
838
839
  android.R.attr.textColorPrimary
839
840
  )
840
841
  else -> theme?.buttonColor ?: resolveColorAttr(
@@ -301,6 +301,16 @@ class RichTextEditorView @JvmOverloads constructor(
301
301
  )
302
302
  }
303
303
 
304
+ internal fun caretRect(): RectF? {
305
+ val rect = editorEditText.caretRect() ?: return null
306
+ return RectF(
307
+ editorViewport.left + editorScrollView.left + editorEditText.left + rect.left,
308
+ editorViewport.top + editorScrollView.top + editorEditText.top + rect.top - editorScrollView.scrollY,
309
+ editorViewport.left + editorScrollView.left + editorEditText.left + rect.right,
310
+ editorViewport.top + editorScrollView.top + editorEditText.top + rect.bottom - editorScrollView.scrollY
311
+ )
312
+ }
313
+
304
314
  internal fun maximumImageWidthPx(): Float {
305
315
  val availableWidth =
306
316
  maxOf(editorEditText.width, editorEditText.measuredWidth) -
@@ -91,6 +91,18 @@ export type EditorToolbarItem = EditorToolbarLeafItem | EditorToolbarGroupItem |
91
91
  type: 'separator';
92
92
  key?: string;
93
93
  };
94
+ export interface EditorToolbarFrame {
95
+ x: number;
96
+ y: number;
97
+ width: number;
98
+ height: number;
99
+ }
100
+ export declare function isEditorToolbarFocusPreservationActive(): boolean;
101
+ export declare function useEditorToolbarFrames(): readonly EditorToolbarFrame[];
102
+ export declare function _setEditorToolbarFrameForTests(id: number, frame: EditorToolbarFrame | null): void;
103
+ export declare function _resetEditorToolbarFrameRegistryForTests(): void;
104
+ export declare function _beginEditorToolbarInteractionForTests(): void;
105
+ export declare function _endEditorToolbarInteractionForTests(): void;
94
106
  export declare const DEFAULT_EDITOR_TOOLBAR_ITEMS: readonly EditorToolbarItem[];
95
107
  export interface EditorToolbarProps {
96
108
  /** Currently active marks and nodes from the Rust engine. */
@@ -145,5 +157,10 @@ export interface EditorToolbarProps {
145
157
  theme?: EditorToolbarTheme;
146
158
  /** Whether to render the built-in top separator line. */
147
159
  showTopBorder?: boolean;
160
+ /**
161
+ * Keep NativeRichTextEditor focused when this toolbar is rendered outside
162
+ * the editor wrapper. Defaults to true.
163
+ */
164
+ preserveEditorFocus?: boolean;
148
165
  }
149
- export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems, theme, showTopBorder, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;
166
+ export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems, theme, showTopBorder, preserveEditorFocus, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;
@@ -1,11 +1,95 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_EDITOR_TOOLBAR_ITEMS = void 0;
4
+ exports.isEditorToolbarFocusPreservationActive = isEditorToolbarFocusPreservationActive;
5
+ exports.useEditorToolbarFrames = useEditorToolbarFrames;
6
+ exports._setEditorToolbarFrameForTests = _setEditorToolbarFrameForTests;
7
+ exports._resetEditorToolbarFrameRegistryForTests = _resetEditorToolbarFrameRegistryForTests;
8
+ exports._beginEditorToolbarInteractionForTests = _beginEditorToolbarInteractionForTests;
9
+ exports._endEditorToolbarInteractionForTests = _endEditorToolbarInteractionForTests;
4
10
  exports.EditorToolbar = EditorToolbar;
5
11
  const jsx_runtime_1 = require("react/jsx-runtime");
6
12
  const vector_icons_1 = require("@expo/vector-icons");
7
13
  const react_1 = require("react");
8
14
  const react_native_1 = require("react-native");
15
+ const editorToolbarFrames = new Map();
16
+ const editorToolbarFrameListeners = new Set();
17
+ let nextEditorToolbarRegistrationId = 1;
18
+ let activeEditorToolbarInteractions = 0;
19
+ let editorToolbarFocusPreserveUntil = 0;
20
+ const EDITOR_TOOLBAR_FOCUS_PRESERVE_MS = 750;
21
+ function areToolbarFramesEqual(left, right) {
22
+ return (left?.x === right?.x &&
23
+ left?.y === right?.y &&
24
+ left?.width === right?.width &&
25
+ left?.height === right?.height);
26
+ }
27
+ function notifyEditorToolbarFrameListeners() {
28
+ editorToolbarFrameListeners.forEach((listener) => listener());
29
+ }
30
+ function getEditorToolbarFramesSnapshot() {
31
+ return Array.from(editorToolbarFrames.values());
32
+ }
33
+ function registerEditorToolbarFrame(id, frame) {
34
+ if (frame == null || frame.width <= 0 || frame.height <= 0) {
35
+ if (editorToolbarFrames.delete(id)) {
36
+ notifyEditorToolbarFrameListeners();
37
+ }
38
+ return;
39
+ }
40
+ const currentFrame = editorToolbarFrames.get(id);
41
+ if (areToolbarFramesEqual(currentFrame, frame)) {
42
+ return;
43
+ }
44
+ editorToolbarFrames.set(id, frame);
45
+ notifyEditorToolbarFrameListeners();
46
+ }
47
+ function unregisterEditorToolbarFrame(id) {
48
+ if (editorToolbarFrames.delete(id)) {
49
+ notifyEditorToolbarFrameListeners();
50
+ }
51
+ }
52
+ function preserveEditorToolbarFocusForNextBlur() {
53
+ editorToolbarFocusPreserveUntil = Date.now() + EDITOR_TOOLBAR_FOCUS_PRESERVE_MS;
54
+ }
55
+ function beginEditorToolbarInteraction() {
56
+ activeEditorToolbarInteractions += 1;
57
+ preserveEditorToolbarFocusForNextBlur();
58
+ }
59
+ function endEditorToolbarInteraction() {
60
+ activeEditorToolbarInteractions = Math.max(0, activeEditorToolbarInteractions - 1);
61
+ preserveEditorToolbarFocusForNextBlur();
62
+ }
63
+ function isEditorToolbarFocusPreservationActive() {
64
+ return activeEditorToolbarInteractions > 0 || Date.now() <= editorToolbarFocusPreserveUntil;
65
+ }
66
+ function useEditorToolbarFrames() {
67
+ const [frames, setFrames] = (0, react_1.useState)(getEditorToolbarFramesSnapshot);
68
+ (0, react_1.useEffect)(() => {
69
+ const listener = () => setFrames(getEditorToolbarFramesSnapshot());
70
+ editorToolbarFrameListeners.add(listener);
71
+ listener();
72
+ return () => {
73
+ editorToolbarFrameListeners.delete(listener);
74
+ };
75
+ }, []);
76
+ return frames;
77
+ }
78
+ function _setEditorToolbarFrameForTests(id, frame) {
79
+ registerEditorToolbarFrame(id, frame);
80
+ }
81
+ function _resetEditorToolbarFrameRegistryForTests() {
82
+ editorToolbarFrames.clear();
83
+ activeEditorToolbarInteractions = 0;
84
+ editorToolbarFocusPreserveUntil = 0;
85
+ notifyEditorToolbarFrameListeners();
86
+ }
87
+ function _beginEditorToolbarInteractionForTests() {
88
+ beginEditorToolbarInteraction();
89
+ }
90
+ function _endEditorToolbarInteractionForTests() {
91
+ endEditorToolbarInteraction();
92
+ }
9
93
  function defaultIcon(id) {
10
94
  return { type: 'default', id };
11
95
  }
@@ -109,16 +193,22 @@ const DEFAULT_MATERIAL_ICONS = {
109
193
  undo: 'undo',
110
194
  redo: 'redo',
111
195
  };
112
- function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems = exports.DEFAULT_EDITOR_TOOLBAR_ITEMS, theme, showTopBorder, }) {
196
+ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems = exports.DEFAULT_EDITOR_TOOLBAR_ITEMS, theme, showTopBorder, preserveEditorFocus = true, }) {
113
197
  const marks = activeState.marks ?? {};
114
198
  const nodes = activeState.nodes ?? {};
115
199
  const commands = activeState.commands ?? {};
116
200
  const allowedMarks = activeState.allowedMarks ?? [];
117
201
  const insertableNodes = activeState.insertableNodes ?? [];
202
+ const rootRef = (0, react_1.useRef)(null);
118
203
  const groupButtonRefs = (0, react_1.useRef)(new Map());
119
204
  const { width: windowWidth, height: windowHeight } = (0, react_native_1.useWindowDimensions)();
120
205
  const [expandedGroupKey, setExpandedGroupKey] = (0, react_1.useState)(null);
121
206
  const [menuState, setMenuState] = (0, react_1.useState)(null);
207
+ const toolbarInteractionActiveRef = (0, react_1.useRef)(false);
208
+ const registrationIdRef = (0, react_1.useRef)(null);
209
+ if (registrationIdRef.current == null) {
210
+ registrationIdRef.current = nextEditorToolbarRegistrationId++;
211
+ }
122
212
  const isMarkActive = (0, react_1.useCallback)((mark) => !!marks[mark], [marks]);
123
213
  const isInList = !!nodes['bulletList'] || !!nodes['orderedList'];
124
214
  const canIndentList = isInList && !!commands['indentList'];
@@ -372,6 +462,56 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
372
462
  };
373
463
  }, [expandedGroupKey, menuState?.groupKey, resolveButton, toolbarItems]);
374
464
  const resolvedShowTopBorder = showTopBorder ?? theme?.showTopBorder ?? true;
465
+ const publishToolbarFrame = (0, react_1.useCallback)(() => {
466
+ const registrationId = registrationIdRef.current;
467
+ const toolbar = rootRef.current;
468
+ if (!preserveEditorFocus || registrationId == null || !toolbar) {
469
+ if (registrationId != null) {
470
+ unregisterEditorToolbarFrame(registrationId);
471
+ }
472
+ return;
473
+ }
474
+ if (typeof toolbar.measureInWindow !== 'function') {
475
+ return;
476
+ }
477
+ toolbar.measureInWindow((x, y, width, height) => {
478
+ registerEditorToolbarFrame(registrationId, { x, y, width, height });
479
+ });
480
+ }, [preserveEditorFocus]);
481
+ const handleToolbarLayout = (0, react_1.useCallback)(() => {
482
+ requestAnimationFrame(publishToolbarFrame);
483
+ }, [publishToolbarFrame]);
484
+ (0, react_1.useEffect)(() => {
485
+ if (!preserveEditorFocus) {
486
+ const registrationId = registrationIdRef.current;
487
+ if (registrationId != null) {
488
+ unregisterEditorToolbarFrame(registrationId);
489
+ }
490
+ return;
491
+ }
492
+ const frame = requestAnimationFrame(publishToolbarFrame);
493
+ return () => cancelAnimationFrame(frame);
494
+ }, [
495
+ expandedGroupKey,
496
+ menuState?.groupKey,
497
+ preserveEditorFocus,
498
+ publishToolbarFrame,
499
+ renderedItems.length,
500
+ windowHeight,
501
+ windowWidth,
502
+ ]);
503
+ (0, react_1.useEffect)(() => {
504
+ const registrationId = registrationIdRef.current;
505
+ return () => {
506
+ if (toolbarInteractionActiveRef.current) {
507
+ toolbarInteractionActiveRef.current = false;
508
+ endEditorToolbarInteraction();
509
+ }
510
+ if (registrationId != null) {
511
+ unregisterEditorToolbarFrame(registrationId);
512
+ }
513
+ };
514
+ }, []);
375
515
  (0, react_1.useEffect)(() => {
376
516
  if (expandedGroupKey != null && !groupsByKey.has(expandedGroupKey)) {
377
517
  setExpandedGroupKey(null);
@@ -389,6 +529,18 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
389
529
  }
390
530
  setMenuState(null);
391
531
  }, []);
532
+ const handleToolbarPressIn = (0, react_1.useCallback)(() => {
533
+ if (preserveEditorFocus && !toolbarInteractionActiveRef.current) {
534
+ toolbarInteractionActiveRef.current = true;
535
+ beginEditorToolbarInteraction();
536
+ }
537
+ }, [preserveEditorFocus]);
538
+ const handleToolbarPressOut = (0, react_1.useCallback)(() => {
539
+ if (preserveEditorFocus && toolbarInteractionActiveRef.current) {
540
+ toolbarInteractionActiveRef.current = false;
541
+ endEditorToolbarInteraction();
542
+ }
543
+ }, [preserveEditorFocus]);
392
544
  const handleGroupPress = (0, react_1.useCallback)((group) => {
393
545
  if (group.isDisabled) {
394
546
  return;
@@ -442,7 +594,7 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
442
594
  else {
443
595
  groupButtonRefs.current.delete(anchorGroupKey);
444
596
  }
445
- }, collapsable: false, style: styles.buttonAnchor, children: [(0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { onPress: onPress, disabled: button.isDisabled, style: [
597
+ }, collapsable: false, style: styles.buttonAnchor, children: [(0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { onPressIn: handleToolbarPressIn, onPressOut: handleToolbarPressOut, onPress: onPress, disabled: button.isDisabled, style: [
446
598
  styles.button,
447
599
  {
448
600
  borderRadius: theme?.buttonBorderRadius ?? BUTTON_RADIUS,
@@ -460,7 +612,7 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
460
612
  styles.separator,
461
613
  theme?.separatorColor != null ? { backgroundColor: theme.separatorColor } : null,
462
614
  ] }, key));
463
- return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
615
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { ref: rootRef, collapsable: false, onLayout: handleToolbarLayout, style: [
464
616
  styles.container,
465
617
  !resolvedShowTopBorder && styles.containerWithoutTopBorder,
466
618
  theme?.backgroundColor != null ? { backgroundColor: theme.backgroundColor } : null,
@@ -512,7 +664,7 @@ function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic
512
664
  : button.isDisabled
513
665
  ? disabledColor
514
666
  : defaultColor;
515
- return ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { onPress: () => handleButtonPress(button), disabled: button.isDisabled, style: ({ pressed }) => [
667
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { onPressIn: handleToolbarPressIn, onPressOut: handleToolbarPressOut, onPress: () => handleButtonPress(button), disabled: button.isDisabled, style: ({ pressed }) => [
516
668
  styles.menuItem,
517
669
  button.isActive && {
518
670
  backgroundColor: theme?.buttonActiveBackgroundColor ?? ACTIVE_BG,
@@ -135,6 +135,8 @@ export interface NativeRichTextEditorRef {
135
135
  getContentJson(): DocumentJSON;
136
136
  /** Get the plain text content (no markup). */
137
137
  getTextContent(): string;
138
+ /** Get the current caret rectangle in editor-local layout coordinates. */
139
+ getCaretRect(): Promise<NativeRichTextEditorCaretRect | null>;
138
140
  /** Undo the last operation. */
139
141
  undo(): void;
140
142
  /** Redo the last undone operation. */
@@ -144,4 +146,18 @@ export interface NativeRichTextEditorRef {
144
146
  /** Check if redo is available. */
145
147
  canRedo(): boolean;
146
148
  }
149
+ export interface NativeRichTextEditorCaretRect {
150
+ /** Left edge of the caret, relative to the editor root view. */
151
+ x: number;
152
+ /** Top edge of the caret, relative to the editor root view. */
153
+ y: number;
154
+ /** Caret width. */
155
+ width: number;
156
+ /** Caret height. */
157
+ height: number;
158
+ /** Current editor root view width. */
159
+ editorWidth: number;
160
+ /** Current editor root view height. */
161
+ editorHeight: number;
162
+ }
147
163
  export declare const NativeRichTextEditor: React.ForwardRefExoticComponent<NativeRichTextEditorProps & React.RefAttributes<NativeRichTextEditorRef>>;
@@ -423,6 +423,44 @@ function serializeRemoteSelections(remoteSelections) {
423
423
  }
424
424
  return stringifyCachedJson(remoteSelections);
425
425
  }
426
+ function areToolbarFramesEqual(left, right) {
427
+ return (left?.x === right?.x &&
428
+ left?.y === right?.y &&
429
+ left?.width === right?.width &&
430
+ left?.height === right?.height);
431
+ }
432
+ function serializeToolbarFrames(frames) {
433
+ if (!frames || frames.length === 0) {
434
+ return undefined;
435
+ }
436
+ return JSON.stringify(frames.length === 1 ? frames[0] : { frames });
437
+ }
438
+ function parseCaretRectJson(raw) {
439
+ if (!raw) {
440
+ return null;
441
+ }
442
+ try {
443
+ const parsed = JSON.parse(raw);
444
+ const x = typeof parsed.x === 'number' ? parsed.x : null;
445
+ const y = typeof parsed.y === 'number' ? parsed.y : null;
446
+ const width = typeof parsed.width === 'number' ? parsed.width : null;
447
+ const height = typeof parsed.height === 'number' ? parsed.height : null;
448
+ const editorWidth = typeof parsed.editorWidth === 'number' ? parsed.editorWidth : null;
449
+ const editorHeight = typeof parsed.editorHeight === 'number' ? parsed.editorHeight : null;
450
+ if (x == null ||
451
+ y == null ||
452
+ width == null ||
453
+ height == null ||
454
+ editorWidth == null ||
455
+ editorHeight == null) {
456
+ return null;
457
+ }
458
+ return { x, y, width, height, editorWidth, editorHeight };
459
+ }
460
+ catch {
461
+ return null;
462
+ }
463
+ }
426
464
  const serializedJsonCache = new WeakMap();
427
465
  function stringifyCachedJson(value) {
428
466
  if (value != null && typeof value === 'object') {
@@ -463,7 +501,9 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
463
501
  const [isReady, setIsReady] = (0, react_1.useState)(false);
464
502
  const [editorInstanceId, setEditorInstanceId] = (0, react_1.useState)(0);
465
503
  const [isFocused, setIsFocused] = (0, react_1.useState)(false);
466
- const [toolbarFrameJson, setToolbarFrameJson] = (0, react_1.useState)(undefined);
504
+ const isFocusedRef = (0, react_1.useRef)(false);
505
+ const [inlineToolbarFrame, setInlineToolbarFrame] = (0, react_1.useState)(null);
506
+ const registeredToolbarFrames = (0, EditorToolbar_1.useEditorToolbarFrames)();
467
507
  const [pendingNativeUpdate, setPendingNativeUpdate] = (0, react_1.useState)({
468
508
  json: undefined,
469
509
  revision: 0,
@@ -745,21 +785,21 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
745
785
  const updateToolbarFrame = (0, react_1.useCallback)(() => {
746
786
  const toolbar = toolbarRef.current;
747
787
  if (!toolbar) {
748
- setToolbarFrameJson(undefined);
788
+ setInlineToolbarFrame(null);
749
789
  return;
750
790
  }
751
791
  toolbar.measureInWindow((x, y, width, height) => {
752
792
  if (width <= 0 || height <= 0) {
753
- setToolbarFrameJson(undefined);
793
+ setInlineToolbarFrame(null);
754
794
  return;
755
795
  }
756
- const nextJson = JSON.stringify({ x, y, width, height });
757
- setToolbarFrameJson((prev) => (prev === nextJson ? prev : nextJson));
796
+ const nextFrame = { x, y, width, height };
797
+ setInlineToolbarFrame((prev) => areToolbarFramesEqual(prev, nextFrame) ? prev : nextFrame);
758
798
  });
759
799
  }, []);
760
800
  (0, react_1.useEffect)(() => {
761
801
  if (!(showToolbar && toolbarPlacement === 'inline' && isFocused && editable)) {
762
- setToolbarFrameJson(undefined);
802
+ setInlineToolbarFrame(null);
763
803
  return;
764
804
  }
765
805
  const frame = requestAnimationFrame(() => {
@@ -836,19 +876,40 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
836
876
  }
837
877
  onSelectionChangeRef.current?.(nextSelection);
838
878
  }, [syncSelectionStateFromUpdate]);
879
+ const refocusAfterToolbarInteraction = (0, react_1.useCallback)(() => {
880
+ nativeViewRef.current?.focus?.();
881
+ requestAnimationFrame(() => {
882
+ nativeViewRef.current?.focus?.();
883
+ });
884
+ setTimeout(() => {
885
+ nativeViewRef.current?.focus?.();
886
+ }, 50);
887
+ }, []);
839
888
  const handleFocusChange = (0, react_1.useCallback)((event) => {
840
889
  const { isFocused: focused } = event.nativeEvent;
890
+ if (!focused &&
891
+ editable &&
892
+ isFocusedRef.current &&
893
+ (0, EditorToolbar_1.isEditorToolbarFocusPreservationActive)()) {
894
+ setIsFocused(true);
895
+ refocusAfterToolbarInteraction();
896
+ return;
897
+ }
898
+ const wasFocused = isFocusedRef.current;
899
+ isFocusedRef.current = focused;
841
900
  setIsFocused(focused);
842
901
  if (!focused) {
843
902
  setMentionQueryEvent(null);
844
903
  }
845
904
  if (focused) {
846
- onFocusRef.current?.();
905
+ if (!wasFocused) {
906
+ onFocusRef.current?.();
907
+ }
847
908
  }
848
- else {
909
+ else if (wasFocused) {
849
910
  onBlurRef.current?.();
850
911
  }
851
- }, []);
912
+ }, [editable, refocusAfterToolbarInteraction]);
852
913
  (0, react_1.useEffect)(() => {
853
914
  if (addons?.mentions != null) {
854
915
  return;
@@ -1130,6 +1191,13 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1130
1191
  return '';
1131
1192
  return bridgeRef.current.getHtml().replace(/<[^>]+>/g, '');
1132
1193
  },
1194
+ async getCaretRect() {
1195
+ const nativeView = nativeViewRef.current;
1196
+ if (!nativeView?.getCaretRect)
1197
+ return null;
1198
+ const raw = await Promise.resolve(nativeView.getCaretRect());
1199
+ return parseCaretRectJson(raw);
1200
+ },
1133
1201
  undo() {
1134
1202
  runAndApply(() => bridgeRef.current?.undo() ?? null);
1135
1203
  },
@@ -1219,6 +1287,14 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1219
1287
  nativeViewStyleParts.push({ height: autoGrowHeight });
1220
1288
  }
1221
1289
  const nativeViewStyle = nativeViewStyleParts.length <= 1 ? nativeViewStyleParts[0] : nativeViewStyleParts;
1290
+ const toolbarFrameJson = serializeToolbarFrames(isFocused && editable
1291
+ ? [
1292
+ ...(toolbarPlacement === 'inline' && inlineToolbarFrame != null
1293
+ ? [inlineToolbarFrame]
1294
+ : []),
1295
+ ...registeredToolbarFrames,
1296
+ ]
1297
+ : undefined);
1222
1298
  const jsToolbar = ((0, jsx_runtime_1.jsx)(react_native_1.View, { ref: toolbarRef, testID: 'native-editor-js-toolbar', style: [
1223
1299
  styles.inlineToolbar,
1224
1300
  { marginTop: inlineToolbarMarginTop },
@@ -1270,7 +1346,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1270
1346
  '#8E8E93',
1271
1347
  },
1272
1348
  ], children: suggestion.subtitle })) : null] })) }, suggestion.key));
1273
- }) }) })) : ((0, jsx_runtime_1.jsx)(EditorToolbar_1.EditorToolbar, { activeState: activeState, historyState: historyState, toolbarItems: toolbarItems, theme: theme?.toolbar, showTopBorder: inlineToolbarShowTopBorder, onToggleMark: (mark) => runAndApply(() => bridgeRef.current?.toggleMark(mark) ?? null, {
1349
+ }) }) })) : ((0, jsx_runtime_1.jsx)(EditorToolbar_1.EditorToolbar, { activeState: activeState, historyState: historyState, toolbarItems: toolbarItems, theme: theme?.toolbar, showTopBorder: inlineToolbarShowTopBorder, preserveEditorFocus: false, onToggleMark: (mark) => runAndApply(() => bridgeRef.current?.toggleMark(mark) ?? null, {
1274
1350
  skipNativeApplyIfContentUnchanged: true,
1275
1351
  }), onToggleListType: (listType) => runAndApply(() => bridgeRef.current?.toggleList(listType) ?? null), onToggleHeading: (level) => runAndApply(() => bridgeRef.current?.toggleHeading(level) ?? null), onToggleBlockquote: () => runAndApply(() => bridgeRef.current?.toggleBlockquote() ?? null), onInsertNodeType: (nodeType) => runAndApply(() => bridgeRef.current?.insertNode(nodeType) ?? null), onRunCommand: (command) => {
1276
1352
  switch (command) {
@@ -1296,7 +1372,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1296
1372
  }), onToggleStrike: () => runAndApply(() => bridgeRef.current?.toggleMark('strike') ?? null, {
1297
1373
  skipNativeApplyIfContentUnchanged: true,
1298
1374
  }), onToggleBulletList: () => runAndApply(() => bridgeRef.current?.toggleList('bulletList') ?? null), onToggleOrderedList: () => runAndApply(() => bridgeRef.current?.toggleList('orderedList') ?? null), onIndentList: () => runAndApply(() => bridgeRef.current?.indentListItem() ?? null), onOutdentList: () => runAndApply(() => bridgeRef.current?.outdentListItem() ?? null), onInsertHorizontalRule: () => runAndApply(() => bridgeRef.current?.insertNode('horizontalRule') ?? null), onInsertLineBreak: () => runAndApply(() => bridgeRef.current?.insertNode('hardBreak') ?? null), onUndo: () => runAndApply(() => bridgeRef.current?.undo() ?? null), onRedo: () => runAndApply(() => bridgeRef.current?.redo() ?? null) })) }));
1299
- return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.container, containerStyle], children: [(0, jsx_runtime_1.jsx)(NativeEditorView, { ref: nativeViewRef, style: nativeViewStyle, editorId: editorInstanceId, placeholder: placeholder, editable: editable, autoFocus: autoFocus, showToolbar: showToolbar, toolbarPlacement: toolbarPlacement, heightBehavior: heightBehavior, allowImageResizing: allowImageResizing, themeJson: themeJson, addonsJson: addonsJson, toolbarItemsJson: toolbarItemsJson, remoteSelectionsJson: remoteSelectionsJson, toolbarFrameJson: toolbarPlacement === 'inline' && isFocused ? toolbarFrameJson : undefined, editorUpdateJson: pendingNativeUpdate.json, editorUpdateRevision: pendingNativeUpdate.revision, onEditorUpdate: handleUpdate, onSelectionChange: handleSelectionChange, onFocusChange: handleFocusChange, onContentHeightChange: handleContentHeightChange, onToolbarAction: handleToolbarAction, onAddonEvent: handleAddonEvent }, DEV_NATIVE_VIEW_KEY), shouldRenderJsToolbar && jsToolbar] }));
1375
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.container, containerStyle], children: [(0, jsx_runtime_1.jsx)(NativeEditorView, { ref: nativeViewRef, style: nativeViewStyle, editorId: editorInstanceId, placeholder: placeholder, editable: editable, autoFocus: autoFocus, showToolbar: showToolbar, toolbarPlacement: toolbarPlacement, heightBehavior: heightBehavior, allowImageResizing: allowImageResizing, themeJson: themeJson, addonsJson: addonsJson, toolbarItemsJson: toolbarItemsJson, remoteSelectionsJson: remoteSelectionsJson, toolbarFrameJson: toolbarFrameJson, editorUpdateJson: pendingNativeUpdate.json, editorUpdateRevision: pendingNativeUpdate.revision, onEditorUpdate: handleUpdate, onSelectionChange: handleSelectionChange, onFocusChange: handleFocusChange, onContentHeightChange: handleContentHeightChange, onToolbarAction: handleToolbarAction, onAddonEvent: handleAddonEvent }, DEV_NATIVE_VIEW_KEY), shouldRenderJsToolbar && jsToolbar] }));
1300
1376
  });
1301
1377
  const styles = react_native_1.StyleSheet.create({
1302
1378
  container: {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { NativeRichTextEditor, type NativeRichTextEditorProps, type NativeRichTextEditorRef, type NativeRichTextEditorHeightBehavior, type NativeRichTextEditorToolbarPlacement, type RemoteSelectionDecoration, type LinkRequestContext, type ImageRequestContext, } from './NativeRichTextEditor';
1
+ export { NativeRichTextEditor, type NativeRichTextEditorProps, type NativeRichTextEditorRef, type NativeRichTextEditorCaretRect, type NativeRichTextEditorHeightBehavior, type NativeRichTextEditorToolbarPlacement, type RemoteSelectionDecoration, type LinkRequestContext, type ImageRequestContext, } from './NativeRichTextEditor';
2
2
  export { NativeProseViewer, type NativeProseViewerProps, type NativeProseViewerAddons, type NativeProseViewerMentionsAddonConfig, type NativeProseViewerMentionPrefix, type NativeProseViewerLinkPressEvent, type NativeProseViewerMentionRenderContext, type NativeProseViewerMentionPressEvent, } from './NativeProseViewer';
3
3
  export { EditorToolbar, DEFAULT_EDITOR_TOOLBAR_ITEMS, type EditorToolbarProps, type EditorToolbarItem, type EditorToolbarLeafItem, type EditorToolbarGroupChildItem, type EditorToolbarGroupItem, type EditorToolbarGroupPresentation, type EditorToolbarIcon, type EditorToolbarDefaultIconId, type EditorToolbarSFSymbolIcon, type EditorToolbarMaterialIcon, type EditorToolbarCommand, type EditorToolbarHeadingLevel, type EditorToolbarListType, } from './EditorToolbar';
4
4
  export type { EditorContentInsets, EditorTheme, EditorTextStyle, EditorLinkTheme, EditorHeadingTheme, EditorListTheme, EditorHorizontalRuleTheme, EditorMentionTheme, EditorToolbarTheme, EditorToolbarAppearance, EditorFontStyle, EditorFontWeight, } from './EditorTheme';
@@ -8,32 +8,32 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>libeditor_core.a</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64_x86_64-simulator</string>
11
+ <string>ios-arm64</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>libeditor_core.a</string>
14
14
  <key>SupportedArchitectures</key>
15
15
  <array>
16
16
  <string>arm64</string>
17
- <string>x86_64</string>
18
17
  </array>
19
18
  <key>SupportedPlatform</key>
20
19
  <string>ios</string>
21
- <key>SupportedPlatformVariant</key>
22
- <string>simulator</string>
23
20
  </dict>
24
21
  <dict>
25
22
  <key>BinaryPath</key>
26
23
  <string>libeditor_core.a</string>
27
24
  <key>LibraryIdentifier</key>
28
- <string>ios-arm64</string>
25
+ <string>ios-arm64_x86_64-simulator</string>
29
26
  <key>LibraryPath</key>
30
27
  <string>libeditor_core.a</string>
31
28
  <key>SupportedArchitectures</key>
32
29
  <array>
33
30
  <string>arm64</string>
31
+ <string>x86_64</string>
34
32
  </array>
35
33
  <key>SupportedPlatform</key>
36
34
  <string>ios</string>
35
+ <key>SupportedPlatformVariant</key>
36
+ <string>simulator</string>
37
37
  </dict>
38
38
  </array>
39
39
  <key>CFBundlePackageType</key>
@@ -1626,7 +1626,8 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1626
1626
  inputViewStyle: .keyboard
1627
1627
  )
1628
1628
  private let accessoryPlaceholder = EditorAccessoryPlaceholderView(frame: .zero)
1629
- private var toolbarFrameInWindow: CGRect?
1629
+ private var toolbarFramesInWindow: [CGRect] = []
1630
+ private var lastToolbarTouchUptime: TimeInterval = -Double.infinity
1630
1631
  private var didApplyAutoFocus = false
1631
1632
  private var toolbarState = NativeToolbarState.empty
1632
1633
  private var toolbarItems: [NativeToolbarItem] = NativeToolbarItem.defaults
@@ -1855,17 +1856,32 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1855
1856
  lastToolbarFrameJSON = toolbarFrameJson
1856
1857
  guard let toolbarFrameJson,
1857
1858
  let data = toolbarFrameJson.data(using: .utf8),
1858
- let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1859
- let x = (raw["x"] as? NSNumber)?.doubleValue,
1859
+ let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
1860
+ else {
1861
+ toolbarFramesInWindow = []
1862
+ return
1863
+ }
1864
+
1865
+ if let frameDictionaries = raw["frames"] as? [[String: Any]] {
1866
+ toolbarFramesInWindow = frameDictionaries.compactMap(Self.toolbarFrame(from:))
1867
+ return
1868
+ }
1869
+
1870
+ toolbarFramesInWindow = Self.toolbarFrame(from: raw).map { [$0] } ?? []
1871
+ }
1872
+
1873
+ private static func toolbarFrame(from raw: [String: Any]) -> CGRect? {
1874
+ guard let x = (raw["x"] as? NSNumber)?.doubleValue,
1860
1875
  let y = (raw["y"] as? NSNumber)?.doubleValue,
1861
1876
  let width = (raw["width"] as? NSNumber)?.doubleValue,
1862
- let height = (raw["height"] as? NSNumber)?.doubleValue
1877
+ let height = (raw["height"] as? NSNumber)?.doubleValue,
1878
+ width > 0,
1879
+ height > 0
1863
1880
  else {
1864
- toolbarFrameInWindow = nil
1865
- return
1881
+ return nil
1866
1882
  }
1867
1883
 
1868
- toolbarFrameInWindow = CGRect(x: x, y: y, width: width, height: height)
1884
+ return CGRect(x: x, y: y, width: width, height: height)
1869
1885
  }
1870
1886
 
1871
1887
  func setPendingEditorUpdateJson(_ editorUpdateJson: String?) {
@@ -1904,6 +1920,30 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1904
1920
  richTextView.textView.resignFirstResponder()
1905
1921
  }
1906
1922
 
1923
+ func getCaretRectJson() -> String? {
1924
+ layoutIfNeeded()
1925
+ richTextView.layoutIfNeeded()
1926
+
1927
+ guard let caretRect = richTextView.currentCaretRect() else {
1928
+ return nil
1929
+ }
1930
+ let editorRect = richTextView.convert(caretRect, to: self)
1931
+ let payload: [String: Any] = [
1932
+ "x": editorRect.minX,
1933
+ "y": editorRect.minY,
1934
+ "width": editorRect.width,
1935
+ "height": editorRect.height,
1936
+ "editorWidth": bounds.width,
1937
+ "editorHeight": bounds.height,
1938
+ ]
1939
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
1940
+ let json = String(data: data, encoding: .utf8)
1941
+ else {
1942
+ return nil
1943
+ }
1944
+ return json
1945
+ }
1946
+
1907
1947
  // MARK: - Focus Notifications
1908
1948
 
1909
1949
  @objc private func textViewDidBeginEditing(_ notification: Notification) {
@@ -1914,6 +1954,13 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1914
1954
  }
1915
1955
 
1916
1956
  @objc private func textViewDidEndEditing(_ notification: Notification) {
1957
+ if shouldPreserveFocusAfterToolbarTouch() {
1958
+ DispatchQueue.main.async { [weak self] in
1959
+ self?.richTextView.textView.becomeFirstResponder()
1960
+ }
1961
+ return
1962
+ }
1963
+
1917
1964
  uninstallOutsideTapRecognizer()
1918
1965
  richTextView.textView.refreshSelectionVisualState()
1919
1966
  clearMentionQueryStateAndHidePopover()
@@ -1952,6 +1999,9 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1952
1999
  guard gestureRecognizer === outsideTapGestureRecognizer else { return true }
1953
2000
  guard let tapWindow = gestureWindow ?? window else { return true }
1954
2001
  let locationInWindow = touch.location(in: tapWindow)
2002
+ if isLocationInStandaloneToolbarFrame(locationInWindow) {
2003
+ markRecentToolbarTouch()
2004
+ }
1955
2005
  let result = shouldHandleOutsideTap(
1956
2006
  locationInWindow: locationInWindow,
1957
2007
  touchedView: touch.view
@@ -1959,6 +2009,18 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1959
2009
  return result
1960
2010
  }
1961
2011
 
2012
+ private func markRecentToolbarTouch() {
2013
+ lastToolbarTouchUptime = ProcessInfo.processInfo.systemUptime
2014
+ }
2015
+
2016
+ private func shouldPreserveFocusAfterToolbarTouch() -> Bool {
2017
+ ProcessInfo.processInfo.systemUptime - lastToolbarTouchUptime <= 0.75
2018
+ }
2019
+
2020
+ private func isLocationInStandaloneToolbarFrame(_ locationInWindow: CGPoint) -> Bool {
2021
+ toolbarFramesInWindow.contains(where: { $0.contains(locationInWindow) })
2022
+ }
2023
+
1962
2024
  private func shouldHandleOutsideTap(
1963
2025
  locationInWindow: CGPoint,
1964
2026
  touchedView: UIView?
@@ -1975,7 +2037,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1975
2037
  if let touchedView, touchedView.isDescendant(of: accessoryToolbar) {
1976
2038
  return false
1977
2039
  }
1978
- if let toolbarFrameInWindow, toolbarFrameInWindow.contains(locationInWindow) {
2040
+ if isLocationInStandaloneToolbarFrame(locationInWindow) {
1979
2041
  return false
1980
2042
  }
1981
2043
  return true
@@ -386,6 +386,9 @@ public class NativeEditorModule: Module {
386
386
  AsyncFunction("blur") { (view: NativeEditorExpoView) in
387
387
  view.blur()
388
388
  }
389
+ AsyncFunction("getCaretRect") { (view: NativeEditorExpoView) -> String? in
390
+ view.getCaretRectJson()
391
+ }
389
392
  }
390
393
 
391
394
  View(NativeProseViewerExpoView.self) {
@@ -4669,6 +4669,13 @@ final class RichTextEditorView: UIView {
4669
4669
  remoteSelectionOverlayView.refresh()
4670
4670
  }
4671
4671
 
4672
+ func currentCaretRect() -> CGRect? {
4673
+ guard let selectedTextRange = textView.selectedTextRange else { return nil }
4674
+ let rect = textView.caretRect(for: selectedTextRange.end)
4675
+ guard rect.height > 0 else { return nil }
4676
+ return textView.convert(rect, to: self)
4677
+ }
4678
+
4672
4679
  func remoteSelectionOverlaySubviewsForTesting() -> [UIView] {
4673
4680
  remoteSelectionOverlayView.subviews.filter { !$0.isHidden }
4674
4681
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
4
4
  "description": "Native rich text editor with Rust core for React Native",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/apollohg/react-native-prose-editor",