@apollohg/react-native-prose-editor 0.5.7 → 0.5.9

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.
@@ -339,7 +339,7 @@ class EditorEditText @JvmOverloads constructor(
339
339
  val resolvedTypeface = resolvePlaceholderTypeface(textStyle)
340
340
 
341
341
  return TextPaint(paint).apply {
342
- color = currentHintTextColor
342
+ color = theme?.placeholderColor ?: currentHintTextColor
343
343
  textSize = resolvedTextSize
344
344
  typeface = resolvedTypeface
345
345
  }
@@ -304,6 +304,7 @@ data class EditorTheme(
304
304
  val mentions: EditorMentionTheme? = null,
305
305
  val links: EditorLinkTheme? = null,
306
306
  val toolbar: EditorToolbarTheme? = null,
307
+ val placeholderColor: Int? = null,
307
308
  val backgroundColor: Int? = null,
308
309
  val borderRadius: Float? = null,
309
310
  val contentInsets: EditorContentInsets? = null
@@ -335,6 +336,7 @@ data class EditorTheme(
335
336
  mentions = EditorMentionTheme.fromJson(root.optJSONObject("mentions")),
336
337
  links = EditorLinkTheme.fromJson(root.optJSONObject("links")),
337
338
  toolbar = EditorToolbarTheme.fromJson(root.optJSONObject("toolbar")),
339
+ placeholderColor = parseColor(root.optNullableString("placeholderColor")),
338
340
  backgroundColor = parseColor(root.optNullableString("backgroundColor")),
339
341
  borderRadius = root.optNullableFloat("borderRadius"),
340
342
  contentInsets = EditorContentInsets.fromJson(root.optJSONObject("contentInsets"))
@@ -360,6 +362,10 @@ private fun parseColor(raw: String?): Int? {
360
362
  val value = raw?.trim()?.lowercase() ?: return null
361
363
  if (value.isEmpty()) return null
362
364
 
365
+ when (value) {
366
+ "clear", "transparent" -> return Color.TRANSPARENT
367
+ }
368
+
363
369
  parseCssHexColor(value)?.let { return it }
364
370
 
365
371
  try {
@@ -405,8 +405,19 @@ class NativeEditorExpoView(
405
405
  return false
406
406
  }
407
407
  val toolbarFrame = toolbarFrameInWindow
408
- if (toolbarFrame != null && toolbarFrame.contains(event.rawX, event.rawY)) {
409
- return false
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(
413
+ toolbarFrame.left * density,
414
+ toolbarFrame.top * density,
415
+ toolbarFrame.right * density,
416
+ toolbarFrame.bottom * density
417
+ )
418
+ if (frameInPx.contains(event.rawX, event.rawY)) {
419
+ return false
420
+ }
410
421
  }
411
422
  val rect = Rect()
412
423
  richTextView.editorEditText.getGlobalVisibleRect(rect)
@@ -54,6 +54,7 @@ class RichTextEditorView @JvmOverloads constructor(
54
54
  private var baseBackgroundColor: Int = Color.WHITE
55
55
  private var viewportBottomInsetPx: Int = 0
56
56
  internal var appliedCornerRadiusPx: Float = 0f
57
+ internal var appliedBackgroundColorForTesting: Int = Color.WHITE
57
58
 
58
59
  /** Binds or unbinds the Rust editor instance. */
59
60
  var editorId: Long = 0
@@ -251,13 +252,15 @@ class RichTextEditorView @JvmOverloads constructor(
251
252
 
252
253
  private fun updateScrollContainerAppearance() {
253
254
  val cornerRadiusPx = (theme?.borderRadius ?: 0f) * resources.displayMetrics.density
255
+ val backgroundColor = theme?.backgroundColor ?: baseBackgroundColor
254
256
  editorViewport.background = GradientDrawable().apply {
255
257
  cornerRadius = cornerRadiusPx
256
- setColor(theme?.backgroundColor ?: baseBackgroundColor)
258
+ setColor(backgroundColor)
257
259
  }
258
260
  editorViewport.clipToOutline = cornerRadiusPx > 0f
259
261
  editorScrollView.setBackgroundColor(Color.TRANSPARENT)
260
262
  appliedCornerRadiusPx = cornerRadiusPx
263
+ appliedBackgroundColorForTesting = backgroundColor
261
264
  }
262
265
 
263
266
  private fun updateScrollContainerInsets() {
@@ -96,6 +96,7 @@ export interface EditorTheme {
96
96
  mentions?: EditorMentionTheme;
97
97
  links?: EditorLinkTheme;
98
98
  toolbar?: EditorToolbarTheme;
99
+ placeholderColor?: string;
99
100
  backgroundColor?: string;
100
101
  borderRadius?: number;
101
102
  contentInsets?: EditorContentInsets;
@@ -299,6 +299,7 @@ struct EditorTheme {
299
299
  var mentions: EditorMentionTheme?
300
300
  var links: EditorLinkTheme?
301
301
  var toolbar: EditorToolbarTheme?
302
+ var placeholderColor: UIColor?
302
303
  var backgroundColor: UIColor?
303
304
  var borderRadius: CGFloat?
304
305
  var contentInsets: EditorContentInsets?
@@ -345,6 +346,7 @@ struct EditorTheme {
345
346
  if let toolbar = dictionary["toolbar"] as? [String: Any] {
346
347
  self.toolbar = EditorToolbarTheme(dictionary: toolbar)
347
348
  }
349
+ placeholderColor = EditorTheme.color(from: dictionary["placeholderColor"])
348
350
  backgroundColor = EditorTheme.color(from: dictionary["backgroundColor"])
349
351
  borderRadius = EditorTheme.cgFloat(dictionary["borderRadius"])
350
352
  if let contentInsets = dictionary["contentInsets"] as? [String: Any] {
@@ -1576,6 +1576,46 @@ final class EditorAccessoryToolbarView: UIInputView {
1576
1576
  }
1577
1577
  }
1578
1578
 
1579
+ /// Keeps iOS keyboard integrations on the inputAccessoryView path when the
1580
+ /// visible toolbar is rendered outside the native keyboard accessory.
1581
+ final class EditorAccessoryPlaceholderView: UIView {
1582
+ override init(frame: CGRect) {
1583
+ super.init(
1584
+ frame: CGRect(
1585
+ x: frame.origin.x,
1586
+ y: frame.origin.y,
1587
+ width: frame.width,
1588
+ height: 0
1589
+ )
1590
+ )
1591
+ commonInit()
1592
+ }
1593
+
1594
+ required init?(coder: NSCoder) {
1595
+ return nil
1596
+ }
1597
+
1598
+ override var intrinsicContentSize: CGSize {
1599
+ CGSize(width: UIView.noIntrinsicMetric, height: 0)
1600
+ }
1601
+
1602
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
1603
+ CGSize(width: size.width, height: 0)
1604
+ }
1605
+
1606
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
1607
+ false
1608
+ }
1609
+
1610
+ private func commonInit() {
1611
+ frame.size.height = 0
1612
+ backgroundColor = .clear
1613
+ isOpaque = false
1614
+ isUserInteractionEnabled = false
1615
+ autoresizingMask = [.flexibleWidth]
1616
+ }
1617
+ }
1618
+
1579
1619
  class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognizerDelegate {
1580
1620
 
1581
1621
  // MARK: - Subviews
@@ -1585,6 +1625,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1585
1625
  frame: .zero,
1586
1626
  inputViewStyle: .keyboard
1587
1627
  )
1628
+ private let accessoryPlaceholder = EditorAccessoryPlaceholderView(frame: .zero)
1588
1629
  private var toolbarFrameInWindow: CGRect?
1589
1630
  private var didApplyAutoFocus = false
1590
1631
  private var toolbarState = NativeToolbarState.empty
@@ -2269,14 +2310,33 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2269
2310
  func triggerMentionSuggestionTapForTesting(at index: Int) {
2270
2311
  accessoryToolbar.triggerMentionSuggestionTapForTesting(at: index)
2271
2312
  }
2313
+
2314
+ func inputAccessoryViewForTesting() -> UIView? {
2315
+ richTextView.textView.inputAccessoryView
2316
+ }
2317
+
2318
+ func isUsingAccessoryToolbarForTesting() -> Bool {
2319
+ richTextView.textView.inputAccessoryView === accessoryToolbar
2320
+ }
2321
+
2322
+ func isUsingAccessoryPlaceholderForTesting() -> Bool {
2323
+ richTextView.textView.inputAccessoryView === accessoryPlaceholder
2324
+ }
2325
+
2272
2326
  private func updateAccessoryToolbarVisibility() {
2273
2327
  refreshSystemAssistantToolbarIfNeeded()
2274
- let nextAccessoryView: UIView? = showsToolbar &&
2328
+ let nextAccessoryView: UIView?
2329
+ if showsToolbar &&
2275
2330
  toolbarPlacement == "keyboard" &&
2276
2331
  richTextView.textView.isEditable &&
2277
2332
  !shouldUseSystemAssistantToolbar
2278
- ? accessoryToolbar
2279
- : nil
2333
+ {
2334
+ nextAccessoryView = accessoryToolbar
2335
+ } else if richTextView.textView.isEditable && !shouldUseSystemAssistantToolbar {
2336
+ nextAccessoryView = accessoryPlaceholder
2337
+ } else {
2338
+ nextAccessoryView = nil
2339
+ }
2280
2340
  if richTextView.textView.inputAccessoryView !== nextAccessoryView {
2281
2341
  richTextView.textView.inputAccessoryView = nextAccessoryView
2282
2342
  if richTextView.textView.isFirstResponder {
@@ -865,6 +865,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
865
865
  didSet {
866
866
  renderAppearanceRevision &+= 1
867
867
  placeholderLabel.font = resolvedDefaultFont()
868
+ placeholderLabel.textColor = theme?.placeholderColor ?? .placeholderText
868
869
  backgroundColor = theme?.backgroundColor ?? baseBackgroundColor
869
870
  if let contentInsets = theme?.contentInsets {
870
871
  textContainerInset = UIEdgeInsets(
@@ -1049,7 +1050,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1049
1050
  // Register as the text storage delegate so we can detect unauthorized
1050
1051
  // mutations (reconciliation fallback).
1051
1052
  textStorage.delegate = self
1052
- delegate = self
1053
+ ensureInternalTextViewDelegate()
1053
1054
  addGestureRecognizer(imageSelectionTapRecognizer)
1054
1055
  installImageSelectionTapDependencies()
1055
1056
 
@@ -1160,6 +1161,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1160
1161
  override func becomeFirstResponder() -> Bool {
1161
1162
  let didBecomeFirstResponder = super.becomeFirstResponder()
1162
1163
  if didBecomeFirstResponder {
1164
+ ensureInternalTextViewDelegate()
1165
+ DispatchQueue.main.async { [weak self] in
1166
+ self?.ensureInternalTextViewDelegate()
1167
+ }
1163
1168
  _ = normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded()
1164
1169
  refreshTypingAttributesForSelection()
1165
1170
  }
@@ -1339,6 +1344,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1339
1344
  lastApplyUpdateTraceForTesting
1340
1345
  }
1341
1346
 
1347
+ func isUsingInternalTextViewDelegateForTesting() -> Bool {
1348
+ (delegate as AnyObject?) === (self as AnyObject)
1349
+ }
1350
+
1342
1351
  func blockquoteStripeRectsForTesting() -> [CGRect] {
1343
1352
  editorLayoutManager.blockquoteStripeRectsForTesting(in: textStorage)
1344
1353
  }
@@ -1532,6 +1541,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1532
1541
  /// - id: The editor ID from `editor_create()`.
1533
1542
  /// - initialHTML: Optional HTML to set as initial content.
1534
1543
  func bindEditor(id: UInt64, initialHTML: String? = nil) {
1544
+ ensureInternalTextViewDelegate()
1535
1545
  editorId = id
1536
1546
 
1537
1547
  if let html = initialHTML, !html.isEmpty {
@@ -1560,6 +1570,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1560
1570
  /// Instead of calling `super.insertText()` (which would modify the
1561
1571
  /// underlying text storage directly), we route through Rust.
1562
1572
  override func insertText(_ text: String) {
1573
+ ensureInternalTextViewDelegate()
1563
1574
  guard !isApplyingRustState else {
1564
1575
  super.insertText(text)
1565
1576
  return
@@ -1643,6 +1654,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1643
1654
  /// If there's a range selection, delete the range. If it's a cursor,
1644
1655
  /// delete the character (grapheme cluster) before the cursor.
1645
1656
  override func deleteBackward() {
1657
+ ensureInternalTextViewDelegate()
1646
1658
  guard !isApplyingRustState else {
1647
1659
  super.deleteBackward()
1648
1660
  return
@@ -1836,6 +1848,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1836
1848
  ///
1837
1849
  /// We route the replacement through Rust to keep the document model in sync.
1838
1850
  override func replace(_ range: UITextRange, withText text: String) {
1851
+ ensureInternalTextViewDelegate()
1839
1852
  guard !isApplyingRustState else {
1840
1853
  super.replace(range, withText: text)
1841
1854
  return
@@ -1870,6 +1883,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1870
1883
  /// UITextView display the composing text normally (with its underline
1871
1884
  /// decoration). The text is NOT sent to Rust during composition.
1872
1885
  override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
1886
+ ensureInternalTextViewDelegate()
1873
1887
  isComposing = true
1874
1888
  Self.inputLog.debug(
1875
1889
  "[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
@@ -1885,6 +1899,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1885
1899
  /// replace the marked text with the final text in the text storage,
1886
1900
  /// but we intercept at a higher level.
1887
1901
  override func unmarkText() {
1902
+ ensureInternalTextViewDelegate()
1888
1903
  // Capture the finalized composed text before UIKit clears it.
1889
1904
  composedText = markedTextRange.flatMap { text(in: $0) }
1890
1905
 
@@ -1918,6 +1933,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1918
1933
  /// Attempts to extract HTML from the pasteboard first (for rich text paste),
1919
1934
  /// falling back to plain text.
1920
1935
  override func paste(_ sender: Any?) {
1936
+ ensureInternalTextViewDelegate()
1921
1937
  guard editorId != 0 else {
1922
1938
  super.paste(sender)
1923
1939
  return
@@ -1976,6 +1992,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1976
1992
  /// internally during tap handling and word-boundary resolution.
1977
1993
  func textViewDidChangeSelection(_ textView: UITextView) {
1978
1994
  guard textView === self else { return }
1995
+ ensureInternalTextViewDelegate()
1979
1996
  guard !isApplyingRustState else { return }
1980
1997
  if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
1981
1998
  return
@@ -2028,6 +2045,14 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2028
2045
  interceptedInputDepth > 0
2029
2046
  }
2030
2047
 
2048
+ private func ensureInternalTextViewDelegate() {
2049
+ // Some keyboard integrations replace UITextView's private delegate ivar
2050
+ // directly. The editor must own delegate callbacks so external observers
2051
+ // cannot inspect transient TextKit state during Rust-driven edits.
2052
+ guard (delegate as AnyObject?) !== (self as AnyObject) else { return }
2053
+ delegate = self
2054
+ }
2055
+
2031
2056
  private func performInterceptedInput(_ action: () -> Void) {
2032
2057
  interceptedInputDepth += 1
2033
2058
  Self.inputLog.debug(
@@ -3235,12 +3260,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3235
3260
  var stringMutationNanos: UInt64 = 0
3236
3261
  var attributeMutationNanos: UInt64 = 0
3237
3262
  let previousTextStorageDelegate = textStorage.delegate
3238
- let previousTextViewDelegate = delegate
3239
3263
  textStorage.delegate = nil
3240
3264
  delegate = nil
3241
3265
  defer {
3242
3266
  textStorage.delegate = previousTextStorageDelegate
3243
- delegate = previousTextViewDelegate
3267
+ ensureInternalTextViewDelegate()
3244
3268
  }
3245
3269
  if let replaceRange {
3246
3270
  if shouldUseSmallPatchTextMutation {
@@ -3809,6 +3833,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3809
3833
  ///
3810
3834
  /// - Parameter updateJSON: The JSON string from editor_insert_text, etc.
3811
3835
  func applyUpdateJSON(_ updateJSON: String, notifyDelegate: Bool = true) {
3836
+ ensureInternalTextViewDelegate()
3812
3837
  let totalStartedAt = DispatchTime.now().uptimeNanoseconds
3813
3838
  let parseStartedAt = totalStartedAt
3814
3839
  guard let data = updateJSON.data(using: .utf8),
@@ -3995,6 +4020,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
3995
4020
  /// Used for initial content loading (set_html / set_json return render
3996
4021
  /// elements directly, not wrapped in an EditorUpdate).
3997
4022
  func applyRenderJSON(_ renderJSON: String) {
4023
+ ensureInternalTextViewDelegate()
3998
4024
  Self.updateLog.debug(
3999
4025
  "[applyRenderJSON.begin] before=\(self.textSnapshotSummary(), privacy: .public)"
4000
4026
  )
@@ -4030,7 +4056,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
4030
4056
 
4031
4057
  let totalStartedAt = DispatchTime.now().uptimeNanoseconds
4032
4058
  isApplyingRustState = true
4033
- defer { isApplyingRustState = false }
4059
+ delegate = nil
4060
+ defer {
4061
+ ensureInternalTextViewDelegate()
4062
+ isApplyingRustState = false
4063
+ }
4034
4064
 
4035
4065
  switch type {
4036
4066
  case "text":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
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",