@apollohg/react-native-prose-editor 0.5.9 → 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.
@@ -704,7 +704,8 @@ private final class ImageResizeOverlayView: UIView {
704
704
  /// For CJK input methods, `setMarkedText` / `unmarkText` are used. During
705
705
  /// composition (marked text), we let UITextView handle it normally so the
706
706
  /// user sees their composing text. When composition finalizes (`unmarkText`),
707
- /// we capture the result and route it through Rust.
707
+ /// we commit the final text through Rust at the original Rust-authorized
708
+ /// replacement range.
708
709
  ///
709
710
  /// ## Thread Safety
710
711
  ///
@@ -970,9 +971,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
970
971
  private var pendingDeferredImageSelectionRange: NSRange?
971
972
  private var pendingDeferredImageSelectionGeneration: UInt64 = 0
972
973
 
973
- /// Stores the text that was composed during a marked text session,
974
- /// captured when `unmarkText` is called.
975
- private var composedText: String?
974
+ /// Stores the Rust-authorized scalar range replaced by the active marked
975
+ /// text session. UIKit mutates visible TextKit state during composition,
976
+ /// so final commits must not infer their range from the transient cursor.
977
+ private var markedTextReplacementScalarRange: (from: UInt32, to: UInt32)?
978
+ private var markedTextReplacementUtf16Range: NSRange?
979
+ private var markedTextCompositionText: String?
976
980
 
977
981
  private let editorLayoutManager: EditorLayoutManager
978
982
 
@@ -1580,6 +1584,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1580
1584
  return
1581
1585
  }
1582
1586
 
1587
+ if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1588
+ let replacementRange = trackedMarkedTextReplacementRange()
1589
+ finishTransientMarkedTextMutation()
1590
+ commitMarkedText(text, replacementRange: replacementRange)
1591
+ return
1592
+ }
1593
+
1583
1594
  // Handle Enter/Return as a block split operation.
1584
1595
  if text == "\n" {
1585
1596
  performInterceptedInput {
@@ -1664,6 +1675,15 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1664
1675
  return
1665
1676
  }
1666
1677
 
1678
+ if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1679
+ performTransientTextMutation {
1680
+ super.deleteBackward()
1681
+ }
1682
+ refreshMarkedTextCompositionText()
1683
+ isComposing = markedTextRange != nil || markedTextReplacementScalarRange != nil
1684
+ return
1685
+ }
1686
+
1667
1687
  guard let selectedRange = selectedTextRange else { return }
1668
1688
  Self.inputLog.debug(
1669
1689
  "[deleteBackward] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -1858,6 +1878,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1858
1878
  return
1859
1879
  }
1860
1880
 
1881
+ if markedTextReplacementScalarRange != nil || markedTextRange != nil {
1882
+ let replacementRange = trackedMarkedTextReplacementRange()
1883
+ finishTransientMarkedTextMutation()
1884
+ commitMarkedText(text, replacementRange: replacementRange)
1885
+ return
1886
+ }
1887
+
1861
1888
  let scalarRange = PositionBridge.textRangeToScalarRange(range, in: self)
1862
1889
  Self.inputLog.debug(
1863
1890
  "[replace] text=\(self.preview(text), privacy: .public) scalarRange=\(scalarRange.from)-\(scalarRange.to) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
@@ -1884,46 +1911,178 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1884
1911
  /// decoration). The text is NOT sent to Rust during composition.
1885
1912
  override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
1886
1913
  ensureInternalTextViewDelegate()
1887
- isComposing = true
1914
+ if markedText != nil {
1915
+ captureMarkedTextReplacementRangeIfNeeded()
1916
+ }
1917
+ isComposing = markedText != nil || markedTextReplacementScalarRange != nil
1888
1918
  Self.inputLog.debug(
1889
1919
  "[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
1890
1920
  )
1891
- super.setMarkedText(markedText, selectedRange: selectedRange)
1921
+ performTransientTextMutation {
1922
+ super.setMarkedText(markedText, selectedRange: selectedRange)
1923
+ }
1924
+ if markedText == nil {
1925
+ clearMarkedTextTracking()
1926
+ restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
1927
+ } else {
1928
+ refreshMarkedTextCompositionText(fallback: markedText)
1929
+ }
1892
1930
  }
1893
1931
 
1894
1932
  /// Called when composition is finalized (user selects a candidate or
1895
1933
  /// presses space/enter to commit).
1896
1934
  ///
1897
- /// At this point, the composed text is final. We capture it and send
1898
- /// it to Rust as a single insertion. `unmarkText` in UITextView will
1899
- /// replace the marked text with the final text in the text storage,
1900
- /// but we intercept at a higher level.
1935
+ /// At this point, the composed text is final. We capture it and commit it
1936
+ /// to Rust at the original replacement range captured before UIKit mutated
1937
+ /// the transient text storage.
1901
1938
  override func unmarkText() {
1902
1939
  ensureInternalTextViewDelegate()
1903
- // Capture the finalized composed text before UIKit clears it.
1904
- composedText = markedTextRange.flatMap { text(in: $0) }
1940
+ let composedText = currentMarkedTextForCommit()
1941
+ let replacementRange = trackedMarkedTextReplacementRange()
1905
1942
 
1906
- // Prevent selection sync while UIKit commits the marked text, since
1907
- // the Rust document doesn't have the composed text yet.
1908
- isApplyingRustState = true
1909
- super.unmarkText()
1910
- isApplyingRustState = false
1911
- isComposing = false
1943
+ finishTransientMarkedTextMutation()
1912
1944
 
1913
- // Now route the composed text through Rust. The cursor is at the end
1914
- // of the composed text, so the insert position is cursor - length.
1915
- if let composed = composedText, !composed.isEmpty, editorId != 0 {
1916
- let cursorPos = PositionBridge.cursorScalarOffset(in: self)
1917
- let composedScalars = UInt32(composed.unicodeScalars.count)
1918
- let insertPos = cursorPos >= composedScalars ? cursorPos - composedScalars : 0
1945
+ if let composed = composedText, !composed.isEmpty {
1919
1946
  Self.inputLog.debug(
1920
- "[unmarkText] composed=\(self.preview(composed), privacy: .public) cursorPos=\(cursorPos) insertPos=\(insertPos) selection=\(self.selectionSummary(), privacy: .public)"
1947
+ "[unmarkText] composed=\(self.preview(composed), privacy: .public) replacement=\(self.previewMarkedTextReplacementRange(replacementRange), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
1921
1948
  )
1949
+ commitMarkedText(composed, replacementRange: replacementRange)
1950
+ } else {
1951
+ restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
1952
+ }
1953
+ }
1954
+
1955
+ private func captureMarkedTextReplacementRangeIfNeeded() {
1956
+ guard markedTextReplacementScalarRange == nil else { return }
1957
+
1958
+ guard let selectedRange = selectedTextRange else {
1959
+ let scalarPos = PositionBridge.cursorScalarOffset(in: self)
1960
+ markedTextReplacementScalarRange = (from: scalarPos, to: scalarPos)
1961
+ markedTextReplacementUtf16Range = NSRange(
1962
+ location: Int(scalarPos),
1963
+ length: 0
1964
+ )
1965
+ return
1966
+ }
1967
+
1968
+ let scalarRange = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
1969
+ let startUtf16 = offset(from: beginningOfDocument, to: selectedRange.start)
1970
+ let endUtf16 = offset(from: beginningOfDocument, to: selectedRange.end)
1971
+
1972
+ markedTextReplacementScalarRange = (from: scalarRange.from, to: scalarRange.to)
1973
+ markedTextReplacementUtf16Range = NSRange(
1974
+ location: min(startUtf16, endUtf16),
1975
+ length: abs(endUtf16 - startUtf16)
1976
+ )
1977
+ }
1978
+
1979
+ private func trackedMarkedTextReplacementRange() -> (from: UInt32, to: UInt32)? {
1980
+ if let markedTextReplacementScalarRange {
1981
+ return markedTextReplacementScalarRange
1982
+ }
1983
+ guard let selectedRange = selectedTextRange else { return nil }
1984
+ let scalarRange = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
1985
+ return (from: scalarRange.from, to: scalarRange.to)
1986
+ }
1987
+
1988
+ private func clearMarkedTextTracking() {
1989
+ markedTextReplacementScalarRange = nil
1990
+ markedTextReplacementUtf16Range = nil
1991
+ markedTextCompositionText = nil
1992
+ isComposing = false
1993
+ }
1994
+
1995
+ private func finishTransientMarkedTextMutation() {
1996
+ performTransientTextMutation {
1997
+ super.unmarkText()
1998
+ }
1999
+ clearMarkedTextTracking()
2000
+ }
2001
+
2002
+ private func performTransientTextMutation(_ action: () -> Void) {
2003
+ let wasApplyingRustState = isApplyingRustState
2004
+ isApplyingRustState = true
2005
+ action()
2006
+ isApplyingRustState = wasApplyingRustState
2007
+ }
2008
+
2009
+ private func currentMarkedTextForCommit() -> String? {
2010
+ markedTextRange.flatMap { text(in: $0) }
2011
+ ?? markedTextCompositionText
2012
+ ?? transientMarkedTextFromAuthorizedDiff()
2013
+ }
2014
+
2015
+ private func refreshMarkedTextCompositionText(fallback: String? = nil) {
2016
+ markedTextCompositionText = markedTextRange.flatMap { text(in: $0) }
2017
+ ?? transientMarkedTextFromAuthorizedDiff()
2018
+ ?? fallback
2019
+ }
2020
+
2021
+ private func transientMarkedTextFromAuthorizedDiff() -> String? {
2022
+ guard let replacementRange = markedTextReplacementUtf16Range else { return nil }
2023
+
2024
+ let currentText = textStorage.string as NSString
2025
+ let authorizedText = lastAuthorizedText as NSString
2026
+ let replacementEnd = replacementRange.location + replacementRange.length
2027
+ guard replacementRange.location >= 0,
2028
+ replacementEnd <= authorizedText.length
2029
+ else {
2030
+ return nil
2031
+ }
2032
+
2033
+ let insertedLength = currentText.length - (authorizedText.length - replacementRange.length)
2034
+ guard insertedLength >= 0,
2035
+ replacementRange.location + insertedLength <= currentText.length
2036
+ else {
2037
+ return nil
2038
+ }
2039
+
2040
+ return currentText.substring(
2041
+ with: NSRange(location: replacementRange.location, length: insertedLength)
2042
+ )
2043
+ }
2044
+
2045
+ private func commitMarkedText(
2046
+ _ text: String,
2047
+ replacementRange: (from: UInt32, to: UInt32)?
2048
+ ) {
2049
+ guard editorId != 0 else { return }
2050
+ guard let replacementRange else {
1922
2051
  performInterceptedInput {
1923
- insertTextInRust(composed, at: insertPos)
2052
+ insertTextInRust(text, at: PositionBridge.cursorScalarOffset(in: self))
2053
+ }
2054
+ return
2055
+ }
2056
+
2057
+ performInterceptedInput {
2058
+ if replacementRange.from == replacementRange.to {
2059
+ insertTextInRust(text, at: replacementRange.from)
2060
+ } else {
2061
+ replaceTextRangeInRust(
2062
+ from: replacementRange.from,
2063
+ to: replacementRange.to,
2064
+ with: text
2065
+ )
1924
2066
  }
1925
2067
  }
1926
- composedText = nil
2068
+ }
2069
+
2070
+ private func restoreAuthorizedTextAfterCancelledCompositionIfNeeded() {
2071
+ guard editorId != 0 else { return }
2072
+ guard textStorage.string != lastAuthorizedText else { return }
2073
+
2074
+ let stateJSON = editorGetCurrentState(id: editorId)
2075
+ applyUpdateJSON(stateJSON)
2076
+ }
2077
+
2078
+ private func previewMarkedTextReplacementRange(
2079
+ _ range: (from: UInt32, to: UInt32)?
2080
+ ) -> String {
2081
+ guard let range else { return "none" }
2082
+ let utf16 = markedTextReplacementUtf16Range
2083
+ .map { "\($0.location)..<\($0.location + $0.length)" }
2084
+ ?? "none"
2085
+ return "scalar=\(range.from)..<\(range.to) utf16=\(utf16)"
1927
2086
  }
1928
2087
 
1929
2088
  // MARK: - Paste Handling
@@ -1993,7 +2152,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
1993
2152
  func textViewDidChangeSelection(_ textView: UITextView) {
1994
2153
  guard textView === self else { return }
1995
2154
  ensureInternalTextViewDelegate()
1996
- guard !isApplyingRustState else { return }
2155
+ guard !isApplyingRustState, !isComposing else { return }
1997
2156
  if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
1998
2157
  return
1999
2158
  }
@@ -2174,7 +2333,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2174
2333
  }
2175
2334
 
2176
2335
  private func syncSelectionToRustAndNotifyDelegate() {
2177
- guard !isApplyingRustState, editorId != 0 else { return }
2336
+ guard !isApplyingRustState, !isComposing, editorId != 0 else { return }
2178
2337
  guard let range = selectedTextRange else { return }
2179
2338
 
2180
2339
  let anchor = PositionBridge.textViewToScalar(range.start, in: self)
@@ -2768,6 +2927,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
2768
2927
  applyUpdateJSON(updateJSON)
2769
2928
  }
2770
2929
 
2930
+ private func replaceTextRangeInRust(from: UInt32, to: UInt32, with text: String) {
2931
+ Self.inputLog.debug(
2932
+ "[rust.replaceTextScalar] text=\(self.preview(text), privacy: .public) scalar=\(from)-\(to) selection=\(self.selectionSummary(), privacy: .public)"
2933
+ )
2934
+ let updateJSON = editorReplaceTextScalar(
2935
+ id: editorId,
2936
+ scalarFrom: from,
2937
+ scalarTo: to,
2938
+ text: text
2939
+ )
2940
+ applyUpdateJSON(updateJSON)
2941
+ }
2942
+
2771
2943
  private func insertNodeInRust(_ nodeType: String) {
2772
2944
  guard let selection = currentScalarSelection() else { return }
2773
2945
  Self.inputLog.debug(
@@ -4205,7 +4377,7 @@ extension EditorTextView: NSTextStorageDelegate {
4205
4377
  guard editedMask.contains(.editedCharacters) else { return }
4206
4378
 
4207
4379
  // Skip if this change came from our own Rust apply path.
4208
- guard !isApplyingRustState, !isInterceptingInput else { return }
4380
+ guard !isApplyingRustState, !isInterceptingInput, !isComposing else { return }
4209
4381
 
4210
4382
  // Skip if no editor is bound yet (nothing to reconcile against).
4211
4383
  guard editorId != 0 else { return }
@@ -4247,7 +4419,7 @@ extension EditorTextView: NSTextStorageDelegate {
4247
4419
  guard let self else { return }
4248
4420
  self.reconciliationWorkScheduled = false
4249
4421
 
4250
- guard !self.isApplyingRustState, !self.isInterceptingInput else { return }
4422
+ guard !self.isApplyingRustState, !self.isInterceptingInput, !self.isComposing else { return }
4251
4423
  guard self.editorId != 0 else { return }
4252
4424
  guard self.textStorage.string != self.lastAuthorizedText else { return }
4253
4425
 
@@ -4497,6 +4669,13 @@ final class RichTextEditorView: UIView {
4497
4669
  remoteSelectionOverlayView.refresh()
4498
4670
  }
4499
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
+
4500
4679
  func remoteSelectionOverlaySubviewsForTesting() -> [UIView] {
4501
4680
  remoteSelectionOverlayView.subviews.filter { !$0.isHidden }
4502
4681
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.5.9",
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",