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

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
  /**
@@ -657,7 +669,7 @@ class EditorEditText @JvmOverloads constructor(
657
669
  cursor > 0 &&
658
670
  currentText.getOrNull(cursor - 1) == EMPTY_BLOCK_PLACEHOLDER
659
671
  ) {
660
- val scalarCursor = PositionBridge.utf16ToScalar(cursor - 1, currentText)
672
+ val scalarCursor = PositionBridge.utf16ToScalar(cursor, currentText)
661
673
  deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
662
674
  return
663
675
  }
@@ -710,7 +722,7 @@ class EditorEditText @JvmOverloads constructor(
710
722
  deleteRangeInRust(scalarStart, scalarEnd)
711
723
  } else if (start > 0) {
712
724
  if (currentText.getOrNull(start - 1) == EMPTY_BLOCK_PLACEHOLDER) {
713
- val scalarCursor = PositionBridge.utf16ToScalar(start - 1, currentText)
725
+ val scalarCursor = PositionBridge.utf16ToScalar(start, currentText)
714
726
  deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
715
727
  return
716
728
  }
@@ -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,10 @@ 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
67
+ private var pendingOutsideTapBlur: Runnable? = null
68
+ private var pendingKeyboardDismiss: Runnable? = null
65
69
  private var addons = NativeEditorAddons(null)
66
70
  private var mentionQueryState: MentionQueryState? = null
67
71
  private var lastMentionEventJson: String? = null
@@ -91,7 +95,7 @@ class NativeEditorExpoView(
91
95
  ViewCompat.setOnApplyWindowInsetsListener(keyboardToolbarView) { _, insets ->
92
96
  currentImeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
93
97
  updateKeyboardToolbarLayout()
94
- updateKeyboardToolbarVisibility()
98
+ updateAttachedKeyboardToolbarForInsets()
95
99
  insets
96
100
  }
97
101
 
@@ -101,6 +105,12 @@ class NativeEditorExpoView(
101
105
  installOutsideTapBlurHandlerIfNeeded()
102
106
  refreshMentionQuery()
103
107
  } else {
108
+ if (shouldPreserveFocusAfterToolbarTouch()) {
109
+ richTextView.editorEditText.post {
110
+ focus()
111
+ }
112
+ return@setOnFocusChangeListener
113
+ }
104
114
  uninstallOutsideTapBlurHandler()
105
115
  clearMentionQueryState()
106
116
  }
@@ -195,23 +205,52 @@ class NativeEditorExpoView(
195
205
  if (lastToolbarFrameJson == toolbarFrameJson) return
196
206
  lastToolbarFrameJson = toolbarFrameJson
197
207
  if (toolbarFrameJson.isNullOrBlank()) {
198
- toolbarFrameInWindow = null
208
+ toolbarFramesInWindow = emptyList()
199
209
  return
200
210
  }
201
211
 
202
- toolbarFrameInWindow = try {
212
+ toolbarFramesInWindow = try {
203
213
  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
- )
214
+ val frames = json.optJSONArray("frames")
215
+ if (frames != null) {
216
+ buildList {
217
+ for (index in 0 until frames.length()) {
218
+ frames.optJSONObject(index)?.toToolbarFrame()?.let { add(it) }
219
+ }
220
+ }
221
+ } else {
222
+ listOfNotNull(json.toToolbarFrame())
223
+ }
210
224
  } catch (_: Throwable) {
211
- null
225
+ emptyList()
212
226
  }
213
227
  }
214
228
 
229
+ private fun JSONObject.toToolbarFrame(): RectF? {
230
+ val x = optDouble("x", Double.NaN)
231
+ val y = optDouble("y", Double.NaN)
232
+ val width = optDouble("width", Double.NaN)
233
+ val height = optDouble("height", Double.NaN)
234
+ if (
235
+ x.isNaN() || x.isInfinite() ||
236
+ y.isNaN() || y.isInfinite() ||
237
+ width.isNaN() || width.isInfinite() ||
238
+ height.isNaN() || height.isInfinite()
239
+ ) {
240
+ return null
241
+ }
242
+ if (width <= 0.0 || height <= 0.0) {
243
+ return null
244
+ }
245
+
246
+ return RectF(
247
+ x.toFloat(),
248
+ y.toFloat(),
249
+ (x + width).toFloat(),
250
+ (y + height).toFloat()
251
+ )
252
+ }
253
+
215
254
  fun setPendingEditorUpdateJson(editorUpdateJson: String?) {
216
255
  pendingEditorUpdateJson = editorUpdateJson
217
256
  }
@@ -229,17 +268,83 @@ class NativeEditorExpoView(
229
268
  }
230
269
 
231
270
  fun focus() {
271
+ cancelPendingOutsideTapBlur()
272
+ cancelPendingKeyboardDismiss()
232
273
  richTextView.editorEditText.requestFocus()
274
+ richTextView.editorEditText.post {
275
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
276
+ imm?.showSoftInput(richTextView.editorEditText, InputMethodManager.SHOW_IMPLICIT)
277
+ }
233
278
  }
234
279
 
235
280
  fun blur() {
281
+ cancelPendingOutsideTapBlur()
282
+ cancelPendingKeyboardDismiss()
283
+ clearRecentToolbarTouch()
236
284
  richTextView.editorEditText.clearFocus()
237
285
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
238
286
  imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
239
287
  }
240
288
 
289
+ private fun blurWithDeferredKeyboardDismiss() {
290
+ cancelPendingKeyboardDismiss()
291
+ clearRecentToolbarTouch()
292
+ richTextView.editorEditText.clearFocus()
293
+ val dismiss = Runnable {
294
+ pendingKeyboardDismiss = null
295
+ if (!richTextView.editorEditText.hasFocus()) {
296
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
297
+ imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
298
+ }
299
+ }
300
+ pendingKeyboardDismiss = dismiss
301
+ richTextView.editorEditText.post(dismiss)
302
+ }
303
+
304
+ private fun scheduleOutsideTapBlur() {
305
+ cancelPendingOutsideTapBlur()
306
+ val blur = Runnable {
307
+ pendingOutsideTapBlur = null
308
+ if (richTextView.editorEditText.hasFocus()) {
309
+ blurWithDeferredKeyboardDismiss()
310
+ }
311
+ }
312
+ pendingOutsideTapBlur = blur
313
+ richTextView.editorEditText.postDelayed(blur, OUTSIDE_TAP_BLUR_DELAY_MS)
314
+ }
315
+
316
+ private fun cancelPendingOutsideTapBlur() {
317
+ pendingOutsideTapBlur?.let {
318
+ richTextView.editorEditText.removeCallbacks(it)
319
+ pendingOutsideTapBlur = null
320
+ }
321
+ }
322
+
323
+ private fun cancelPendingKeyboardDismiss() {
324
+ pendingKeyboardDismiss?.let {
325
+ richTextView.editorEditText.removeCallbacks(it)
326
+ pendingKeyboardDismiss = null
327
+ }
328
+ }
329
+
330
+ fun getCaretRectJson(): String? {
331
+ if (width <= 0 || height <= 0) return null
332
+ val rect = richTextView.caretRect() ?: return null
333
+ val density = resources.displayMetrics.density
334
+ return JSONObject()
335
+ .put("x", rect.left / density)
336
+ .put("y", rect.top / density)
337
+ .put("width", rect.width() / density)
338
+ .put("height", rect.height() / density)
339
+ .put("editorWidth", width / density)
340
+ .put("editorHeight", height / density)
341
+ .toString()
342
+ }
343
+
241
344
  override fun onDetachedFromWindow() {
242
345
  super.onDetachedFromWindow()
346
+ cancelPendingOutsideTapBlur()
347
+ cancelPendingKeyboardDismiss()
243
348
  uninstallOutsideTapBlurHandler()
244
349
  detachKeyboardToolbarIfNeeded()
245
350
  }
@@ -374,14 +479,17 @@ class NativeEditorExpoView(
374
479
 
375
480
  val wrappedCallback = object : Window.Callback by currentCallback {
376
481
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
377
- if (
482
+ val shouldBlur =
378
483
  event.action == MotionEvent.ACTION_DOWN &&
379
484
  richTextView.editorEditText.hasFocus() &&
380
485
  isTouchOutsideEditor(event)
381
- ) {
382
- blur()
486
+ val result = currentCallback.dispatchTouchEvent(event)
487
+ if (shouldBlur) {
488
+ scheduleOutsideTapBlur()
489
+ } else if (event.action == MotionEvent.ACTION_DOWN) {
490
+ cancelPendingOutsideTapBlur()
383
491
  }
384
- return currentCallback.dispatchTouchEvent(event)
492
+ return result
385
493
  }
386
494
  }
387
495
 
@@ -402,26 +510,88 @@ class NativeEditorExpoView(
402
510
 
403
511
  private fun isTouchOutsideEditor(event: MotionEvent): Boolean {
404
512
  if (isTouchInsideKeyboardToolbar(event)) {
513
+ markRecentToolbarTouch()
514
+ return false
515
+ }
516
+ if (isTouchInsideStandaloneToolbar(event)) {
517
+ markRecentToolbarTouch()
405
518
  return false
406
519
  }
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(
520
+ val rect = Rect()
521
+ richTextView.editorEditText.getGlobalVisibleRect(rect)
522
+ return !rect.contains(event.rawX.toInt(), event.rawY.toInt())
523
+ }
524
+
525
+ private fun markRecentToolbarTouch() {
526
+ lastToolbarTouchUptimeMs = SystemClock.uptimeMillis()
527
+ }
528
+
529
+ private fun clearRecentToolbarTouch() {
530
+ lastToolbarTouchUptimeMs = null
531
+ }
532
+
533
+ private fun shouldPreserveFocusAfterToolbarTouch(): Boolean {
534
+ val lastToolbarTouch = lastToolbarTouchUptimeMs ?: return false
535
+ val elapsedMs = SystemClock.uptimeMillis() - lastToolbarTouch
536
+ return elapsedMs in 0L..TOOLBAR_FOCUS_PRESERVE_MS
537
+ }
538
+
539
+ internal fun markRecentToolbarTouchForTesting() {
540
+ markRecentToolbarTouch()
541
+ }
542
+
543
+ internal fun shouldPreserveFocusAfterToolbarTouchForTesting(): Boolean =
544
+ shouldPreserveFocusAfterToolbarTouch()
545
+
546
+ private fun isTouchInsideStandaloneToolbar(event: MotionEvent): Boolean {
547
+ val visibleWindowFrame = Rect()
548
+ getWindowVisibleDisplayFrame(visibleWindowFrame)
549
+ return isPointInsideStandaloneToolbar(event.rawX, event.rawY, visibleWindowFrame)
550
+ }
551
+
552
+ internal fun isPointInsideStandaloneToolbarForTesting(
553
+ rawX: Float,
554
+ rawY: Float,
555
+ visibleWindowFrame: Rect
556
+ ): Boolean = isPointInsideStandaloneToolbar(rawX, rawY, visibleWindowFrame)
557
+
558
+ private fun isPointInsideStandaloneToolbar(
559
+ rawX: Float,
560
+ rawY: Float,
561
+ visibleWindowFrame: Rect
562
+ ): Boolean {
563
+ if (toolbarFramesInWindow.isEmpty()) {
564
+ return false
565
+ }
566
+ // toolbarFrame is in DP from React Native's measureInWindow. On Android
567
+ // that is window-relative after visible-window insets are subtracted,
568
+ // while rawX/rawY are screen pixels. Fabric/newer implementations may
569
+ // differ here, so accept both window-relative and raw-screen comparisons.
570
+ val density = resources.displayMetrics.density
571
+ val hitSlopPx = TOOLBAR_HIT_SLOP_DP * density
572
+ val eventX = rawX - visibleWindowFrame.left
573
+ val eventY = rawY - visibleWindowFrame.top
574
+ for (toolbarFrame in toolbarFramesInWindow) {
575
+ val windowFrameInPx = RectF(
413
576
  toolbarFrame.left * density,
414
577
  toolbarFrame.top * density,
415
578
  toolbarFrame.right * density,
416
579
  toolbarFrame.bottom * density
417
- )
418
- if (frameInPx.contains(event.rawX, event.rawY)) {
419
- return false
580
+ ).apply {
581
+ inset(-hitSlopPx, -hitSlopPx)
582
+ }
583
+ val screenFrameInPx = RectF(windowFrameInPx).apply {
584
+ offset(visibleWindowFrame.left.toFloat(), visibleWindowFrame.top.toFloat())
585
+ }
586
+ if (
587
+ windowFrameInPx.contains(rawX, rawY) ||
588
+ windowFrameInPx.contains(eventX, eventY) ||
589
+ screenFrameInPx.contains(rawX, rawY)
590
+ ) {
591
+ return true
420
592
  }
421
593
  }
422
- val rect = Rect()
423
- richTextView.editorEditText.getGlobalVisibleRect(rect)
424
- return !rect.contains(event.rawX.toInt(), event.rawY.toInt())
594
+ return false
425
595
  }
426
596
 
427
597
  private fun isTouchInsideKeyboardToolbar(event: MotionEvent): Boolean {
@@ -433,6 +603,12 @@ class NativeEditorExpoView(
433
603
  return rect.contains(event.rawX.toInt(), event.rawY.toInt())
434
604
  }
435
605
 
606
+ private companion object {
607
+ private const val TOOLBAR_HIT_SLOP_DP = 8f
608
+ private const val TOOLBAR_FOCUS_PRESERVE_MS = 750L
609
+ private const val OUTSIDE_TAP_BLUR_DELAY_MS = 100L
610
+ }
611
+
436
612
  private fun resolveActivity(context: Context): Activity? {
437
613
  var current: Context? = context
438
614
  while (current is ContextWrapper) {
@@ -658,6 +834,11 @@ class NativeEditorExpoView(
658
834
  keyboardToolbarView.layoutParams = params
659
835
  }
660
836
 
837
+ private fun updateAttachedKeyboardToolbarForInsets() {
838
+ keyboardToolbarView.visibility = if (currentImeBottom > 0) View.VISIBLE else View.INVISIBLE
839
+ updateEditorViewportInset()
840
+ }
841
+
661
842
  private fun updateKeyboardToolbarVisibility() {
662
843
  val shouldAttach =
663
844
  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;