@apollohg/react-native-prose-editor 0.5.15 → 0.5.17

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.
@@ -1,6 +1,113 @@
1
1
  import ExpoModulesCore
2
2
  import UIKit
3
3
 
4
+ private final class WeakNativeEditorExpoView {
5
+ weak var view: NativeEditorExpoView?
6
+
7
+ init(_ view: NativeEditorExpoView) {
8
+ self.view = view
9
+ }
10
+ }
11
+
12
+ final class NativeEditorViewRegistry {
13
+ static let shared = NativeEditorViewRegistry()
14
+
15
+ private var viewsByEditorId: [UInt64: WeakNativeEditorExpoView] = [:]
16
+ private var destroyedEditorIds: Set<UInt64> = []
17
+
18
+ private init() {}
19
+
20
+ func markEditorCreated(editorId: UInt64) {
21
+ guard editorId != 0 else { return }
22
+ performOnMain {
23
+ destroyedEditorIds.remove(editorId)
24
+ }
25
+ }
26
+
27
+ func isDestroyed(editorId: UInt64) -> Bool {
28
+ guard editorId != 0 else { return false }
29
+ return performOnMain {
30
+ destroyedEditorIds.contains(editorId)
31
+ }
32
+ }
33
+
34
+ @discardableResult
35
+ func register(editorId: UInt64, view: NativeEditorExpoView) -> Bool {
36
+ guard editorId != 0 else { return false }
37
+ return performOnMain {
38
+ guard !destroyedEditorIds.contains(editorId) else { return false }
39
+ viewsByEditorId[editorId] = WeakNativeEditorExpoView(view)
40
+ return true
41
+ }
42
+ }
43
+
44
+ func unregister(editorId: UInt64, view: NativeEditorExpoView) {
45
+ guard editorId != 0 else { return }
46
+ performOnMain {
47
+ guard viewsByEditorId[editorId]?.view === view else { return }
48
+ viewsByEditorId.removeValue(forKey: editorId)
49
+ }
50
+ }
51
+
52
+ func invalidateDestroyedEditor(editorId: UInt64) {
53
+ guard editorId != 0 else { return }
54
+ performOnMain {
55
+ destroyedEditorIds.insert(editorId)
56
+ guard let view = viewsByEditorId.removeValue(forKey: editorId)?.view else {
57
+ return
58
+ }
59
+ view.handleEditorDestroyed(editorId)
60
+ }
61
+ }
62
+
63
+ func prepareForCommandJSON(editorId: UInt64) -> String {
64
+ let prepare = { () -> String in
65
+ if self.destroyedEditorIds.contains(editorId) {
66
+ return Self.commandPreparationJSON(ready: false, blockedReason: "destroyed")
67
+ }
68
+ guard let view = self.viewsByEditorId[editorId]?.view else {
69
+ self.viewsByEditorId.removeValue(forKey: editorId)
70
+ return Self.commandPreparationJSON(ready: true)
71
+ }
72
+ return view.prepareForEditorCommandJSON()
73
+ }
74
+
75
+ return performOnMain(prepare)
76
+ }
77
+
78
+ static func commandPreparationJSON(
79
+ ready: Bool,
80
+ updateJSON: String? = nil,
81
+ blockedReason: String? = nil
82
+ ) -> String {
83
+ var payload: [String: Any] = ["ready": ready]
84
+ if let updateJSON {
85
+ payload["updateJSON"] = updateJSON
86
+ }
87
+ if let blockedReason {
88
+ payload["blockedReason"] = blockedReason
89
+ }
90
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
91
+ let json = String(data: data, encoding: .utf8)
92
+ else {
93
+ if let blockedReason {
94
+ return ready
95
+ ? "{\"ready\":true,\"blockedReason\":\"\(blockedReason)\"}"
96
+ : "{\"ready\":false,\"blockedReason\":\"\(blockedReason)\"}"
97
+ }
98
+ return ready ? "{\"ready\":true}" : "{\"ready\":false}"
99
+ }
100
+ return json
101
+ }
102
+
103
+ private func performOnMain<T>(_ work: () -> T) -> T {
104
+ if Thread.isMainThread {
105
+ return work()
106
+ }
107
+ return DispatchQueue.main.sync(execute: work)
108
+ }
109
+ }
110
+
4
111
  private struct NativeToolbarState {
5
112
  let marks: [String: Bool]
6
113
  let nodes: [String: Bool]
@@ -1638,6 +1745,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1638
1745
  private var addons = NativeEditorAddons(mentions: nil)
1639
1746
  private var mentionQueryState: MentionQueryState?
1640
1747
  private var lastMentionEventJSON: String?
1748
+ private var desiredThemeJSON: String?
1641
1749
  private var lastThemeJSON: String?
1642
1750
  private var lastAddonsJSON: String?
1643
1751
  private var lastRemoteSelectionsJSON: String?
@@ -1646,6 +1754,29 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1646
1754
  private var pendingEditorUpdateJSON: String?
1647
1755
  private var pendingEditorUpdateRevision = 0
1648
1756
  private var appliedEditorUpdateRevision = 0
1757
+ private var pendingEditorUpdateRetryScheduled = false
1758
+ private var pendingEditorUpdateRetryEditorId: UInt64?
1759
+ private var pendingEditorUpdateRetryGeneration: UInt64 = 0
1760
+ private var pendingViewCommandUpdateJSON: String?
1761
+ private var pendingViewCommandUpdateEditorId: UInt64?
1762
+ private var pendingViewCommandUpdateRetryScheduled = false
1763
+ private var pendingViewCommandUpdateRetryGeneration: UInt64 = 0
1764
+ private var pendingEditableRetryValue: Bool?
1765
+ private var pendingEditableRetryEditorId: UInt64?
1766
+ private var pendingEditableRetryScheduled = false
1767
+ private var pendingEditableRetryGeneration: UInt64 = 0
1768
+ private var pendingThemeRetryJSON: String?
1769
+ private var pendingThemeRetryEditorId: UInt64?
1770
+ private var pendingThemeRetryScheduled = false
1771
+ private var pendingThemeRetryGeneration: UInt64 = 0
1772
+ private var pendingAccessoryRetryActions: [PendingAccessoryRetryAction] = []
1773
+ private var invalidatedAccessoryRetryActions = Set<PendingAccessoryRetryAction>()
1774
+ private var pendingAccessoryRetryEditorId: UInt64?
1775
+ private var pendingAccessoryRetryScheduled = false
1776
+ private var pendingAccessoryRetryGeneration: UInt64 = 0
1777
+ private var pendingMentionSuggestionRetry: PendingMentionSuggestionRetry?
1778
+ private var pendingMentionSuggestionRetryScheduled = false
1779
+ private var pendingMentionSuggestionRetryGeneration: UInt64 = 0
1649
1780
  private lazy var outsideTapGestureRecognizer: UITapGestureRecognizer = {
1650
1781
  let recognizer = UITapGestureRecognizer(
1651
1782
  target: self,
@@ -1672,6 +1803,31 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1672
1803
  let onAddonEvent = EventDispatcher()
1673
1804
  private var lastEmittedContentHeight: CGFloat = 0
1674
1805
  private var cachedAutoGrowContentHeight: CGFloat = 0
1806
+ private var lastAddonEventJSONForTestingValue: String?
1807
+
1808
+ private enum PendingAccessoryRetryAction: Hashable {
1809
+ case reloadInputViews
1810
+ case refreshMentionQuery
1811
+ case clearMentionQueryState
1812
+ case updateAccessoryToolbarVisibility
1813
+ }
1814
+
1815
+ private struct PendingMentionSuggestionRetry {
1816
+ let suggestionKey: String
1817
+ let editorId: UInt64
1818
+ let trigger: String
1819
+ let query: String
1820
+ let anchor: UInt32
1821
+ let head: UInt32
1822
+ let documentVersion: Int?
1823
+ let textSnapshot: String
1824
+ }
1825
+
1826
+ private struct MentionRetryTextDiff {
1827
+ let start: Int
1828
+ let oldEnd: Int
1829
+ let newEnd: Int
1830
+ }
1675
1831
 
1676
1832
  // MARK: - Initialization
1677
1833
 
@@ -1705,6 +1861,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1705
1861
  }
1706
1862
 
1707
1863
  deinit {
1864
+ NativeEditorViewRegistry.shared.unregister(editorId: richTextView.editorId, view: self)
1708
1865
  NotificationCenter.default.removeObserver(self)
1709
1866
  }
1710
1867
 
@@ -1743,8 +1900,65 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1743
1900
 
1744
1901
  // MARK: - Editor Binding
1745
1902
 
1903
+ func handleEditorDestroyed(_ editorId: UInt64) {
1904
+ guard editorId != 0 else { return }
1905
+ guard richTextView.editorId == editorId || richTextView.textView.editorId == editorId else {
1906
+ NativeEditorViewRegistry.shared.unregister(editorId: editorId, view: self)
1907
+ return
1908
+ }
1909
+
1910
+ NativeEditorViewRegistry.shared.unregister(editorId: editorId, view: self)
1911
+ clearPendingEditorUpdateRetries()
1912
+ clearPendingViewCommandUpdateRetry()
1913
+ clearPendingEditableRetry()
1914
+ clearPendingThemeRetry()
1915
+ clearPendingAccessoryRetry()
1916
+ clearPendingMentionSuggestionRetry()
1917
+ lastMentionEventJSON = nil
1918
+ richTextView.textView.resignFirstResponder()
1919
+ richTextView.editorId = 0
1920
+ mentionQueryState = nil
1921
+ _ = accessoryToolbar.setMentionSuggestions([])
1922
+ toolbarState = .empty
1923
+ accessoryToolbar.apply(state: .empty)
1924
+ uninstallOutsideTapRecognizer()
1925
+ refreshSystemAssistantToolbarIfNeeded()
1926
+ }
1927
+
1746
1928
  func setEditorId(_ id: UInt64) {
1929
+ let previousEditorId = richTextView.editorId
1930
+ if id != 0 && NativeEditorViewRegistry.shared.isDestroyed(editorId: id) {
1931
+ if previousEditorId == id {
1932
+ handleEditorDestroyed(id)
1933
+ } else {
1934
+ setEditorId(0)
1935
+ }
1936
+ return
1937
+ }
1938
+ guard previousEditorId != id else {
1939
+ if id != 0 {
1940
+ if !NativeEditorViewRegistry.shared.register(editorId: id, view: self) {
1941
+ handleEditorDestroyed(id)
1942
+ }
1943
+ }
1944
+ return
1945
+ }
1946
+ if previousEditorId != id {
1947
+ NativeEditorViewRegistry.shared.unregister(editorId: previousEditorId, view: self)
1948
+ clearPendingEditorUpdateRetries()
1949
+ clearPendingViewCommandUpdateRetry()
1950
+ clearPendingEditableRetry()
1951
+ clearPendingThemeRetry()
1952
+ clearPendingAccessoryRetry()
1953
+ clearPendingMentionSuggestionRetry()
1954
+ }
1747
1955
  richTextView.editorId = id
1956
+ if id != 0 {
1957
+ guard NativeEditorViewRegistry.shared.register(editorId: id, view: self) else {
1958
+ handleEditorDestroyed(id)
1959
+ return
1960
+ }
1961
+ }
1748
1962
  if id != 0 {
1749
1963
  let stateJSON = editorGetCurrentState(id: id)
1750
1964
  if let state = NativeToolbarState(updateJSON: stateJSON) {
@@ -1758,25 +1972,225 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1758
1972
  toolbarState = .empty
1759
1973
  accessoryToolbar.apply(state: .empty)
1760
1974
  }
1975
+ if desiredThemeJSON != lastThemeJSON {
1976
+ setThemeJson(desiredThemeJSON)
1977
+ }
1761
1978
  refreshSystemAssistantToolbarIfNeeded()
1762
1979
  refreshMentionQuery()
1763
1980
  }
1764
1981
 
1765
1982
  func setThemeJson(_ themeJson: String?) {
1766
- guard lastThemeJSON != themeJson else { return }
1767
- lastThemeJSON = themeJson
1983
+ desiredThemeJSON = themeJson
1984
+ guard lastThemeJSON != themeJson else {
1985
+ clearPendingThemeRetry()
1986
+ return
1987
+ }
1768
1988
  let theme = EditorTheme.from(json: themeJson)
1769
- richTextView.applyTheme(theme)
1989
+ guard richTextView.applyTheme(theme) else {
1990
+ scheduleThemeRetry(themeJson)
1991
+ return
1992
+ }
1993
+ lastThemeJSON = themeJson
1994
+ clearPendingThemeRetry()
1770
1995
  accessoryToolbar.apply(theme: theme?.toolbar)
1771
1996
  accessoryToolbar.apply(mentionTheme: theme?.mentions ?? addons.mentions?.theme)
1772
1997
  refreshSystemAssistantToolbarIfNeeded()
1773
1998
  if richTextView.textView.isFirstResponder,
1774
1999
  (richTextView.textView.inputAccessoryView === accessoryToolbar || shouldUseSystemAssistantToolbar)
1775
2000
  {
1776
- richTextView.textView.reloadInputViews()
2001
+ reloadInputViewsAfterPreparingOrRetry()
2002
+ }
2003
+ }
2004
+
2005
+ private func clearPendingEditorUpdateRetries() {
2006
+ pendingEditorUpdateJSON = nil
2007
+ pendingEditorUpdateRevision = 0
2008
+ appliedEditorUpdateRevision = 0
2009
+ pendingEditorUpdateRetryScheduled = false
2010
+ pendingEditorUpdateRetryEditorId = nil
2011
+ pendingEditorUpdateRetryGeneration &+= 1
2012
+ }
2013
+
2014
+ private func clearPendingViewCommandUpdateRetry() {
2015
+ pendingViewCommandUpdateJSON = nil
2016
+ pendingViewCommandUpdateEditorId = nil
2017
+ pendingViewCommandUpdateRetryScheduled = false
2018
+ pendingViewCommandUpdateRetryGeneration &+= 1
2019
+ }
2020
+
2021
+ private func clearPendingEditableRetry() {
2022
+ pendingEditableRetryValue = nil
2023
+ pendingEditableRetryEditorId = nil
2024
+ pendingEditableRetryScheduled = false
2025
+ pendingEditableRetryGeneration &+= 1
2026
+ }
2027
+
2028
+ private func clearPendingThemeRetry() {
2029
+ pendingThemeRetryJSON = nil
2030
+ pendingThemeRetryEditorId = nil
2031
+ pendingThemeRetryScheduled = false
2032
+ pendingThemeRetryGeneration &+= 1
2033
+ }
2034
+
2035
+ private func clearPendingAccessoryRetry() {
2036
+ pendingAccessoryRetryActions = []
2037
+ invalidatedAccessoryRetryActions.removeAll()
2038
+ pendingAccessoryRetryEditorId = nil
2039
+ pendingAccessoryRetryScheduled = false
2040
+ pendingAccessoryRetryGeneration &+= 1
2041
+ }
2042
+
2043
+ private func clearPendingMentionSuggestionRetry() {
2044
+ pendingMentionSuggestionRetry = nil
2045
+ pendingMentionSuggestionRetryScheduled = false
2046
+ pendingMentionSuggestionRetryGeneration &+= 1
2047
+ }
2048
+
2049
+ private func scheduleThemeRetry(_ themeJson: String?) {
2050
+ pendingThemeRetryJSON = themeJson
2051
+ pendingThemeRetryEditorId = richTextView.editorId
2052
+ guard !pendingThemeRetryScheduled else { return }
2053
+ pendingThemeRetryScheduled = true
2054
+ pendingThemeRetryGeneration &+= 1
2055
+ let retryGeneration = pendingThemeRetryGeneration
2056
+ DispatchQueue.main.async { [weak self] in
2057
+ guard let self else { return }
2058
+ guard retryGeneration == self.pendingThemeRetryGeneration else { return }
2059
+ let retryJSON = self.pendingThemeRetryJSON
2060
+ self.pendingThemeRetryJSON = nil
2061
+ let retryEditorId = self.pendingThemeRetryEditorId
2062
+ self.pendingThemeRetryEditorId = nil
2063
+ self.pendingThemeRetryScheduled = false
2064
+ guard retryEditorId == self.richTextView.editorId else {
2065
+ self.clearPendingThemeRetry()
2066
+ return
2067
+ }
2068
+ guard retryJSON == self.desiredThemeJSON else {
2069
+ self.clearPendingThemeRetry()
2070
+ return
2071
+ }
2072
+ self.setThemeJson(retryJSON)
2073
+ }
2074
+ }
2075
+
2076
+ private func prepareForInputAccessoryMutationOrRetry(_ action: PendingAccessoryRetryAction) -> Bool {
2077
+ guard richTextView.editorId != 0, richTextView.textView.isFirstResponder else {
2078
+ return true
2079
+ }
2080
+ guard richTextView.textView.prepareForExternalEditorUpdate() else {
2081
+ scheduleAccessoryRetry(action)
2082
+ return false
2083
+ }
2084
+ return true
2085
+ }
2086
+
2087
+ private func reloadInputViewsAfterPreparingOrRetry() {
2088
+ guard prepareForInputAccessoryMutationOrRetry(.reloadInputViews) else { return }
2089
+ richTextView.textView.reloadInputViews()
2090
+ markAccessoryMutationSucceeded(.reloadInputViews)
2091
+ }
2092
+
2093
+ private func scheduleAccessoryRetry(_ action: PendingAccessoryRetryAction) {
2094
+ invalidatedAccessoryRetryActions.remove(action)
2095
+ pendingAccessoryRetryActions.removeAll { $0 == action }
2096
+ pendingAccessoryRetryActions.append(action)
2097
+ pendingAccessoryRetryEditorId = richTextView.editorId
2098
+ guard !pendingAccessoryRetryScheduled else { return }
2099
+ pendingAccessoryRetryScheduled = true
2100
+ pendingAccessoryRetryGeneration &+= 1
2101
+ let retryGeneration = pendingAccessoryRetryGeneration
2102
+ DispatchQueue.main.async { [weak self] in
2103
+ guard let self else { return }
2104
+ guard retryGeneration == self.pendingAccessoryRetryGeneration else { return }
2105
+ guard self.pendingAccessoryRetryEditorId == self.richTextView.editorId else {
2106
+ self.clearPendingAccessoryRetry()
2107
+ return
2108
+ }
2109
+ let actions = self.pendingAccessoryRetryActions
2110
+ self.pendingAccessoryRetryActions = []
2111
+ self.pendingAccessoryRetryEditorId = nil
2112
+ self.pendingAccessoryRetryScheduled = false
2113
+ for index in actions.indices {
2114
+ let action = actions[index]
2115
+ guard retryGeneration == self.pendingAccessoryRetryGeneration else { return }
2116
+ guard !self.invalidatedAccessoryRetryActions.contains(action) else {
2117
+ self.invalidatedAccessoryRetryActions.remove(action)
2118
+ continue
2119
+ }
2120
+ let generationBeforeAction = self.pendingAccessoryRetryGeneration
2121
+ self.performAccessoryRetryAction(action)
2122
+ guard self.pendingAccessoryRetryGeneration == generationBeforeAction else {
2123
+ let remainingIndex = actions.index(after: index)
2124
+ if remainingIndex < actions.endIndex {
2125
+ self.requeueUnprocessedAccessoryRetryActions(actions[remainingIndex...])
2126
+ }
2127
+ return
2128
+ }
2129
+ }
2130
+ self.invalidatedAccessoryRetryActions.subtract(actions)
2131
+ }
2132
+ }
2133
+
2134
+ private func requeueUnprocessedAccessoryRetryActions(
2135
+ _ actions: ArraySlice<PendingAccessoryRetryAction>
2136
+ ) {
2137
+ for action in actions {
2138
+ guard !invalidatedAccessoryRetryActions.contains(action) else {
2139
+ invalidatedAccessoryRetryActions.remove(action)
2140
+ continue
2141
+ }
2142
+ pendingAccessoryRetryActions.removeAll { $0 == action }
2143
+ pendingAccessoryRetryActions.append(action)
2144
+ }
2145
+ if !pendingAccessoryRetryActions.isEmpty {
2146
+ pendingAccessoryRetryEditorId = richTextView.editorId
1777
2147
  }
1778
2148
  }
1779
2149
 
2150
+ private func performAccessoryRetryAction(_ action: PendingAccessoryRetryAction) {
2151
+ switch action {
2152
+ case .reloadInputViews:
2153
+ reloadInputViewsAfterPreparingOrRetry()
2154
+ case .refreshMentionQuery:
2155
+ refreshMentionQuery()
2156
+ case .clearMentionQueryState:
2157
+ clearMentionQueryStateAndHidePopover()
2158
+ case .updateAccessoryToolbarVisibility:
2159
+ updateAccessoryToolbarVisibility()
2160
+ }
2161
+ }
2162
+
2163
+ private func markAccessoryMutationSucceeded(_ action: PendingAccessoryRetryAction) {
2164
+ var invalidated: Set<PendingAccessoryRetryAction> = [action]
2165
+ switch action {
2166
+ case .refreshMentionQuery:
2167
+ invalidated.insert(.clearMentionQueryState)
2168
+ case .clearMentionQueryState:
2169
+ if !hasActiveMentionQueryForCurrentAddons() {
2170
+ invalidated.insert(.refreshMentionQuery)
2171
+ }
2172
+ case .reloadInputViews, .updateAccessoryToolbarVisibility:
2173
+ break
2174
+ }
2175
+ invalidatePendingAccessoryRetries(invalidated)
2176
+ }
2177
+
2178
+ private func invalidatePendingAccessoryRetries(_ actions: Set<PendingAccessoryRetryAction>) {
2179
+ guard !actions.isEmpty else { return }
2180
+ invalidatedAccessoryRetryActions.formUnion(actions)
2181
+ pendingAccessoryRetryActions.removeAll { actions.contains($0) }
2182
+ }
2183
+
2184
+ private func hasActiveMentionQueryForCurrentAddons() -> Bool {
2185
+ guard richTextView.editorId != 0,
2186
+ richTextView.textView.isFirstResponder,
2187
+ let mentions = addons.mentions
2188
+ else {
2189
+ return false
2190
+ }
2191
+ return currentMentionQueryState(trigger: mentions.trigger) != nil
2192
+ }
2193
+
1780
2194
  func setAddonsJson(_ addonsJson: String?) {
1781
2195
  guard lastAddonsJSON != addonsJson else { return }
1782
2196
  lastAddonsJSON = addonsJson
@@ -1792,10 +2206,46 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1792
2206
  }
1793
2207
 
1794
2208
  func setEditable(_ editable: Bool) {
2209
+ if !editable,
2210
+ richTextView.textView.isEditable,
2211
+ richTextView.editorId != 0,
2212
+ !richTextView.textView.prepareForExternalEditorUpdate()
2213
+ {
2214
+ scheduleEditableRetry(editable)
2215
+ return
2216
+ }
2217
+ pendingEditableRetryValue = nil
2218
+ pendingEditableRetryEditorId = nil
2219
+ pendingEditableRetryScheduled = false
1795
2220
  richTextView.textView.isEditable = editable
1796
2221
  updateAccessoryToolbarVisibility()
1797
2222
  }
1798
2223
 
2224
+ private func scheduleEditableRetry(_ editable: Bool) {
2225
+ pendingEditableRetryValue = editable
2226
+ pendingEditableRetryEditorId = richTextView.editorId
2227
+ guard !pendingEditableRetryScheduled else { return }
2228
+ pendingEditableRetryScheduled = true
2229
+ pendingEditableRetryGeneration &+= 1
2230
+ let retryGeneration = pendingEditableRetryGeneration
2231
+ DispatchQueue.main.async { [weak self] in
2232
+ guard let self else { return }
2233
+ guard retryGeneration == self.pendingEditableRetryGeneration else { return }
2234
+ guard let pendingEditable = self.pendingEditableRetryValue else {
2235
+ self.pendingEditableRetryScheduled = false
2236
+ return
2237
+ }
2238
+ guard self.pendingEditableRetryEditorId == self.richTextView.editorId else {
2239
+ self.clearPendingEditableRetry()
2240
+ return
2241
+ }
2242
+ self.pendingEditableRetryValue = nil
2243
+ self.pendingEditableRetryEditorId = nil
2244
+ self.pendingEditableRetryScheduled = false
2245
+ self.setEditable(pendingEditable)
2246
+ }
2247
+ }
2248
+
1799
2249
  func setAutoFocus(_ autoFocus: Bool) {
1800
2250
  guard autoFocus, !didApplyAutoFocus else { return }
1801
2251
  didApplyAutoFocus = true
@@ -1908,18 +2358,96 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1908
2358
  guard pendingEditorUpdateRevision != 0 else { return }
1909
2359
  guard pendingEditorUpdateRevision != appliedEditorUpdateRevision else { return }
1910
2360
  guard let updateJSON = pendingEditorUpdateJSON else { return }
2361
+ guard applyEditorUpdate(updateJSON) else {
2362
+ schedulePendingEditorUpdateRetry()
2363
+ return
2364
+ }
1911
2365
  appliedEditorUpdateRevision = pendingEditorUpdateRevision
1912
- applyEditorUpdate(updateJSON)
2366
+ }
2367
+
2368
+ private func schedulePendingEditorUpdateRetry() {
2369
+ guard !pendingEditorUpdateRetryScheduled else { return }
2370
+ pendingEditorUpdateRetryEditorId = richTextView.editorId
2371
+ pendingEditorUpdateRetryScheduled = true
2372
+ pendingEditorUpdateRetryGeneration &+= 1
2373
+ let retryGeneration = pendingEditorUpdateRetryGeneration
2374
+ DispatchQueue.main.async { [weak self] in
2375
+ guard let self else { return }
2376
+ guard retryGeneration == self.pendingEditorUpdateRetryGeneration else {
2377
+ return
2378
+ }
2379
+ guard self.pendingEditorUpdateRetryEditorId == self.richTextView.editorId else {
2380
+ self.pendingEditorUpdateRetryScheduled = false
2381
+ self.clearPendingEditorUpdateRetries()
2382
+ return
2383
+ }
2384
+ self.pendingEditorUpdateRetryScheduled = false
2385
+ self.pendingEditorUpdateRetryEditorId = nil
2386
+ self.applyPendingEditorUpdateIfNeeded()
2387
+ }
1913
2388
  }
1914
2389
 
1915
2390
  // MARK: - View Commands
1916
2391
 
1917
2392
  /// Apply an editor update from JS. Sets the echo-suppression flag so the
1918
2393
  /// resulting delegate callback is NOT re-dispatched back to JS.
1919
- func applyEditorUpdate(_ updateJson: String) {
2394
+ @discardableResult
2395
+ func applyEditorUpdate(_ updateJson: String) -> Bool {
2396
+ guard richTextView.textView.prepareForExternalEditorUpdate() else {
2397
+ scheduleViewCommandUpdateRetry(updateJson)
2398
+ return false
2399
+ }
1920
2400
  isApplyingJSUpdate = true
2401
+ defer { isApplyingJSUpdate = false }
1921
2402
  richTextView.textView.applyUpdateJSON(updateJson)
1922
- isApplyingJSUpdate = false
2403
+ return true
2404
+ }
2405
+
2406
+ private func scheduleViewCommandUpdateRetry(_ updateJson: String) {
2407
+ pendingViewCommandUpdateJSON = updateJson
2408
+ pendingViewCommandUpdateEditorId = richTextView.editorId
2409
+ guard !pendingViewCommandUpdateRetryScheduled else { return }
2410
+ pendingViewCommandUpdateRetryScheduled = true
2411
+ pendingViewCommandUpdateRetryGeneration &+= 1
2412
+ let retryGeneration = pendingViewCommandUpdateRetryGeneration
2413
+ DispatchQueue.main.async { [weak self] in
2414
+ guard let self else { return }
2415
+ guard retryGeneration == self.pendingViewCommandUpdateRetryGeneration else {
2416
+ return
2417
+ }
2418
+ guard self.pendingViewCommandUpdateJSON != nil else {
2419
+ self.pendingViewCommandUpdateRetryScheduled = false
2420
+ return
2421
+ }
2422
+ guard self.pendingViewCommandUpdateEditorId == self.richTextView.editorId else {
2423
+ self.pendingViewCommandUpdateJSON = nil
2424
+ self.pendingViewCommandUpdateEditorId = nil
2425
+ self.pendingViewCommandUpdateRetryScheduled = false
2426
+ return
2427
+ }
2428
+ guard self.richTextView.editorId != 0 else {
2429
+ self.pendingViewCommandUpdateJSON = nil
2430
+ self.pendingViewCommandUpdateEditorId = nil
2431
+ self.pendingViewCommandUpdateRetryScheduled = false
2432
+ return
2433
+ }
2434
+ self.pendingViewCommandUpdateJSON = nil
2435
+ self.pendingViewCommandUpdateEditorId = nil
2436
+ self.pendingViewCommandUpdateRetryScheduled = false
2437
+ let updateJSON = editorGetCurrentState(id: self.richTextView.editorId)
2438
+ _ = self.applyEditorUpdate(updateJSON)
2439
+ }
2440
+ }
2441
+
2442
+ func prepareForEditorCommandJSON() -> String {
2443
+ isApplyingJSUpdate = true
2444
+ defer { isApplyingJSUpdate = false }
2445
+ let preparation = richTextView.textView.prepareForExternalEditorCommand()
2446
+ return NativeEditorViewRegistry.commandPreparationJSON(
2447
+ ready: preparation.ready,
2448
+ updateJSON: preparation.updateJSON,
2449
+ blockedReason: preparation.blockedReason
2450
+ )
1923
2451
  }
1924
2452
 
1925
2453
  // MARK: - Focus Commands
@@ -2111,6 +2639,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2111
2639
  clearMentionQueryStateAndHidePopover()
2112
2640
  return
2113
2641
  }
2642
+ guard prepareForInputAccessoryMutationOrRetry(.refreshMentionQuery) else { return }
2114
2643
 
2115
2644
  guard let queryState = currentMentionQueryState(trigger: mentions.trigger) else {
2116
2645
  emitMentionQueryChange(query: "", trigger: mentions.trigger, anchor: 0, head: 0, isActive: false)
@@ -2129,6 +2658,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2129
2658
  {
2130
2659
  richTextView.textView.reloadInputViews()
2131
2660
  }
2661
+ markAccessoryMutationSucceeded(.refreshMentionQuery)
2132
2662
  emitMentionQueryChange(
2133
2663
  query: queryState.query,
2134
2664
  trigger: queryState.trigger,
@@ -2139,6 +2669,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2139
2669
  }
2140
2670
 
2141
2671
  private func clearMentionQueryStateAndHidePopover() {
2672
+ guard prepareForInputAccessoryMutationOrRetry(.clearMentionQueryState) else { return }
2142
2673
  mentionQueryState = nil
2143
2674
  let didChangeToolbarHeight = accessoryToolbar.setMentionSuggestions([])
2144
2675
  refreshSystemAssistantToolbarIfNeeded()
@@ -2148,6 +2679,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2148
2679
  {
2149
2680
  richTextView.textView.reloadInputViews()
2150
2681
  }
2682
+ markAccessoryMutationSucceeded(.clearMentionQueryState)
2151
2683
  }
2152
2684
 
2153
2685
  private func emitMentionQueryChange(
@@ -2174,7 +2706,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2174
2706
  }
2175
2707
  guard json != lastMentionEventJSON else { return }
2176
2708
  lastMentionEventJSON = json
2177
- onAddonEvent(["eventJson": json])
2709
+ dispatchAddonEvent(json)
2178
2710
  }
2179
2711
 
2180
2712
  private func resolvedMentionAttrs(
@@ -2207,16 +2739,17 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2207
2739
  else {
2208
2740
  return
2209
2741
  }
2210
- onAddonEvent(["eventJson": json])
2742
+ dispatchAddonEvent(json)
2211
2743
  }
2212
2744
 
2213
2745
  private func emitMentionSelectRequest(
2214
2746
  trigger: String,
2215
2747
  suggestion: NativeMentionSuggestion,
2216
2748
  attrs: [String: Any],
2217
- range: MentionQueryState
2749
+ range: MentionQueryState,
2750
+ preflightUpdateJSON: String? = nil
2218
2751
  ) {
2219
- let payload: [String: Any] = [
2752
+ var payload: [String: Any] = [
2220
2753
  "type": "mentionsSelectRequest",
2221
2754
  "trigger": trigger,
2222
2755
  "suggestionKey": suggestion.key,
@@ -2226,14 +2759,41 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2226
2759
  "head": Int(range.head),
2227
2760
  ],
2228
2761
  ]
2762
+ if let preflightUpdateJSON {
2763
+ payload["updateJson"] = preflightUpdateJSON
2764
+ }
2765
+ if let documentVersion = documentVersion(fromUpdateJSON: preflightUpdateJSON) {
2766
+ payload["documentVersion"] = documentVersion
2767
+ }
2229
2768
  guard let data = try? JSONSerialization.data(withJSONObject: payload),
2230
2769
  let json = String(data: data, encoding: .utf8)
2231
2770
  else {
2232
2771
  return
2233
2772
  }
2773
+ dispatchAddonEvent(json)
2774
+ }
2775
+
2776
+ private func dispatchAddonEvent(_ json: String) {
2777
+ lastAddonEventJSONForTestingValue = json
2234
2778
  onAddonEvent(["eventJson": json])
2235
2779
  }
2236
2780
 
2781
+ private func documentVersion(fromUpdateJSON updateJSON: String?) -> Int? {
2782
+ guard let updateJSON,
2783
+ let data = updateJSON.data(using: .utf8),
2784
+ let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
2785
+ else {
2786
+ return nil
2787
+ }
2788
+ if let version = raw["documentVersion"] as? Int {
2789
+ return version
2790
+ }
2791
+ if let number = raw["documentVersion"] as? NSNumber {
2792
+ return number.intValue
2793
+ }
2794
+ return nil
2795
+ }
2796
+
2237
2797
  private func filteredMentionSuggestions(
2238
2798
  for queryState: MentionQueryState,
2239
2799
  config: NativeMentionsAddonConfig
@@ -2325,20 +2885,90 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2325
2885
  return false
2326
2886
  }
2327
2887
 
2328
- private func insertMentionSuggestion(_ suggestion: NativeMentionSuggestion) {
2888
+ private func insertMentionSuggestion(
2889
+ _ suggestion: NativeMentionSuggestion
2890
+ ) {
2891
+ insertMentionSuggestion(suggestionKey: suggestion.key)
2892
+ }
2893
+
2894
+ private func insertMentionSuggestion(
2895
+ retryScope: PendingMentionSuggestionRetry
2896
+ ) {
2897
+ insertMentionSuggestion(
2898
+ suggestionKey: retryScope.suggestionKey,
2899
+ retryScope: retryScope
2900
+ )
2901
+ }
2902
+
2903
+ private func insertMentionSuggestion(
2904
+ suggestionKey: String,
2905
+ retryScope: PendingMentionSuggestionRetry? = nil
2906
+ ) {
2329
2907
  guard let mentions = addons.mentions,
2330
- let queryState = mentionQueryState
2908
+ mentionQueryState != nil
2331
2909
  else {
2332
2910
  return
2333
2911
  }
2912
+ if let retryScope,
2913
+ !isMentionSuggestionRetryScopeCurrent(retryScope)
2914
+ {
2915
+ return
2916
+ }
2917
+
2918
+ let scopedQueryState = currentMentionQueryState(trigger: mentions.trigger) ?? mentionQueryState
2919
+ guard let scopedQueryState else {
2920
+ clearMentionQueryStateAndHidePopover()
2921
+ return
2922
+ }
2923
+ let preparation = richTextView.textView.prepareForExternalEditorCommand()
2924
+ guard preparation.ready else {
2925
+ scheduleMentionSuggestionRetry(
2926
+ PendingMentionSuggestionRetry(
2927
+ suggestionKey: suggestionKey,
2928
+ editorId: richTextView.editorId,
2929
+ trigger: mentions.trigger,
2930
+ query: scopedQueryState.query,
2931
+ anchor: scopedQueryState.anchor,
2932
+ head: scopedQueryState.head,
2933
+ documentVersion: currentDocumentVersion(),
2934
+ textSnapshot: richTextView.textView.text ?? ""
2935
+ )
2936
+ )
2937
+ return
2938
+ }
2939
+ let queryState = currentMentionQueryState(trigger: mentions.trigger)
2940
+ ?? (richTextView.textView.isFirstResponder ? nil : mentionQueryState)
2941
+ guard let queryState else {
2942
+ clearMentionQueryStateAndHidePopover()
2943
+ return
2944
+ }
2945
+ if let retryScope,
2946
+ !doesMentionQueryState(
2947
+ queryState,
2948
+ match: retryScope,
2949
+ acceptingPreflightDocumentVersion: documentVersion(fromUpdateJSON: preparation.updateJSON),
2950
+ currentText: richTextView.textView.text ?? ""
2951
+ )
2952
+ {
2953
+ return
2954
+ }
2955
+ guard let currentSuggestion = filteredMentionSuggestions(
2956
+ for: queryState,
2957
+ config: mentions
2958
+ ).first(where: { $0.key == suggestionKey }) else {
2959
+ clearMentionQueryStateAndHidePopover()
2960
+ return
2961
+ }
2962
+ mentionQueryState = queryState
2334
2963
 
2335
- let attrs = resolvedMentionAttrs(trigger: mentions.trigger, suggestion: suggestion)
2964
+ let attrs = resolvedMentionAttrs(trigger: mentions.trigger, suggestion: currentSuggestion)
2336
2965
  if mentions.resolveSelectionAttrs || mentions.resolveTheme {
2337
2966
  emitMentionSelectRequest(
2338
2967
  trigger: mentions.trigger,
2339
- suggestion: suggestion,
2968
+ suggestion: currentSuggestion,
2340
2969
  attrs: attrs,
2341
- range: queryState
2970
+ range: queryState,
2971
+ preflightUpdateJSON: preparation.updateJSON
2342
2972
  )
2343
2973
  lastMentionEventJSON = nil
2344
2974
  clearMentionQueryStateAndHidePopover()
@@ -2364,11 +2994,184 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2364
2994
  json: json
2365
2995
  )
2366
2996
  richTextView.textView.applyUpdateJSON(updateJSON)
2367
- emitMentionSelect(trigger: mentions.trigger, suggestion: suggestion, attrs: attrs)
2997
+ emitMentionSelect(trigger: mentions.trigger, suggestion: currentSuggestion, attrs: attrs)
2368
2998
  lastMentionEventJSON = nil
2369
2999
  clearMentionQueryStateAndHidePopover()
2370
3000
  }
2371
3001
 
3002
+ private func scheduleMentionSuggestionRetry(_ retry: PendingMentionSuggestionRetry) {
3003
+ pendingMentionSuggestionRetry = retry
3004
+ guard !pendingMentionSuggestionRetryScheduled else { return }
3005
+ pendingMentionSuggestionRetryScheduled = true
3006
+ pendingMentionSuggestionRetryGeneration &+= 1
3007
+ let retryGeneration = pendingMentionSuggestionRetryGeneration
3008
+ DispatchQueue.main.async { [weak self] in
3009
+ guard let self else { return }
3010
+ guard retryGeneration == self.pendingMentionSuggestionRetryGeneration else { return }
3011
+ guard let retry = self.pendingMentionSuggestionRetry else {
3012
+ self.pendingMentionSuggestionRetryScheduled = false
3013
+ return
3014
+ }
3015
+ guard retry.editorId == self.richTextView.editorId else {
3016
+ self.clearPendingMentionSuggestionRetry()
3017
+ return
3018
+ }
3019
+ self.pendingMentionSuggestionRetry = nil
3020
+ self.pendingMentionSuggestionRetryScheduled = false
3021
+ self.insertMentionSuggestion(retryScope: retry)
3022
+ }
3023
+ }
3024
+
3025
+ private func isMentionSuggestionRetryScopeCurrent(
3026
+ _ retry: PendingMentionSuggestionRetry
3027
+ ) -> Bool {
3028
+ guard retry.editorId == richTextView.editorId,
3029
+ addons.mentions?.trigger == retry.trigger
3030
+ else {
3031
+ return false
3032
+ }
3033
+ let queryState = currentMentionQueryState(trigger: retry.trigger) ?? mentionQueryState
3034
+ guard let queryState else { return false }
3035
+ guard doesMentionQueryStateMatchRetryIdentity(queryState, match: retry) else {
3036
+ return false
3037
+ }
3038
+ return isMentionSuggestionRetryDocumentVersionCurrent(retry)
3039
+ }
3040
+
3041
+ private func doesMentionQueryState(
3042
+ _ queryState: MentionQueryState,
3043
+ match retry: PendingMentionSuggestionRetry,
3044
+ acceptingPreflightDocumentVersion preflightDocumentVersion: Int? = nil,
3045
+ currentText: String? = nil
3046
+ ) -> Bool {
3047
+ guard doesMentionQueryStateMatchRetryIdentity(queryState, match: retry) else {
3048
+ return false
3049
+ }
3050
+
3051
+ let currentVersion = currentDocumentVersion()
3052
+ var acceptedPreflightVersionChange = false
3053
+ if let retryVersion = retry.documentVersion,
3054
+ let currentVersion,
3055
+ currentVersion != retryVersion
3056
+ {
3057
+ guard let preflightDocumentVersion,
3058
+ currentVersion == preflightDocumentVersion
3059
+ else {
3060
+ return false
3061
+ }
3062
+ acceptedPreflightVersionChange = true
3063
+ }
3064
+
3065
+ if queryState.anchor == retry.anchor && queryState.head == retry.head {
3066
+ return true
3067
+ }
3068
+
3069
+ guard acceptedPreflightVersionChange else {
3070
+ return false
3071
+ }
3072
+
3073
+ guard let currentText,
3074
+ let diff = mentionRetryTextDiff(
3075
+ from: retry.textSnapshot,
3076
+ to: currentText
3077
+ ),
3078
+ let mappedRange = mappedMentionRetryRange(retry, through: diff)
3079
+ else {
3080
+ return false
3081
+ }
3082
+
3083
+ return queryState.anchor == mappedRange.anchor && queryState.head == mappedRange.head
3084
+ }
3085
+
3086
+ private func doesMentionQueryStateMatchRetryIdentity(
3087
+ _ queryState: MentionQueryState,
3088
+ match retry: PendingMentionSuggestionRetry
3089
+ ) -> Bool {
3090
+ queryState.trigger == retry.trigger && queryState.query == retry.query
3091
+ }
3092
+
3093
+ private func isMentionSuggestionRetryDocumentVersionCurrent(
3094
+ _ retry: PendingMentionSuggestionRetry
3095
+ ) -> Bool {
3096
+ let currentVersion = currentDocumentVersion()
3097
+ if let retryVersion = retry.documentVersion,
3098
+ let currentVersion,
3099
+ currentVersion != retryVersion
3100
+ {
3101
+ return false
3102
+ }
3103
+ return true
3104
+ }
3105
+
3106
+ private func mentionRetryTextDiff(
3107
+ from oldText: String,
3108
+ to newText: String
3109
+ ) -> MentionRetryTextDiff? {
3110
+ let oldScalars = Array(oldText.unicodeScalars)
3111
+ let newScalars = Array(newText.unicodeScalars)
3112
+ let sharedLength = min(oldScalars.count, newScalars.count)
3113
+
3114
+ var prefix = 0
3115
+ while prefix < sharedLength,
3116
+ oldScalars[prefix] == newScalars[prefix]
3117
+ {
3118
+ prefix += 1
3119
+ }
3120
+
3121
+ var oldEnd = oldScalars.count
3122
+ var newEnd = newScalars.count
3123
+ while oldEnd > prefix,
3124
+ newEnd > prefix,
3125
+ oldScalars[oldEnd - 1] == newScalars[newEnd - 1]
3126
+ {
3127
+ oldEnd -= 1
3128
+ newEnd -= 1
3129
+ }
3130
+
3131
+ guard prefix != oldEnd || prefix != newEnd else {
3132
+ return nil
3133
+ }
3134
+
3135
+ return MentionRetryTextDiff(
3136
+ start: prefix,
3137
+ oldEnd: oldEnd,
3138
+ newEnd: newEnd
3139
+ )
3140
+ }
3141
+
3142
+ private func mappedMentionRetryRange(
3143
+ _ retry: PendingMentionSuggestionRetry,
3144
+ through diff: MentionRetryTextDiff
3145
+ ) -> (anchor: UInt32, head: UInt32)? {
3146
+ let anchor = Int(retry.anchor)
3147
+ let head = Int(retry.head)
3148
+ guard anchor <= head else { return nil }
3149
+
3150
+ if head <= diff.start {
3151
+ return (retry.anchor, retry.head)
3152
+ }
3153
+
3154
+ if anchor >= diff.oldEnd {
3155
+ let delta = diff.newEnd - diff.oldEnd
3156
+ let mappedAnchor = anchor + delta
3157
+ let mappedHead = head + delta
3158
+ guard mappedAnchor >= 0,
3159
+ mappedHead >= mappedAnchor,
3160
+ mappedHead <= Int(UInt32.max)
3161
+ else {
3162
+ return nil
3163
+ }
3164
+ return (UInt32(mappedAnchor), UInt32(mappedHead))
3165
+ }
3166
+
3167
+ return nil
3168
+ }
3169
+
3170
+ private func currentDocumentVersion() -> Int? {
3171
+ guard richTextView.editorId != 0 else { return nil }
3172
+ return documentVersion(fromUpdateJSON: editorGetCurrentState(id: richTextView.editorId))
3173
+ }
3174
+
2372
3175
  func setMentionQueryStateForTesting(_ state: MentionQueryState?) {
2373
3176
  mentionQueryState = state
2374
3177
  }
@@ -2381,6 +3184,14 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2381
3184
  accessoryToolbar.setMentionSuggestions(suggestions)
2382
3185
  }
2383
3186
 
3187
+ func isShowingMentionSuggestionsForTesting() -> Bool {
3188
+ accessoryToolbar.isShowingMentionSuggestions
3189
+ }
3190
+
3191
+ func lastAddonEventJSONForTesting() -> String? {
3192
+ lastAddonEventJSONForTestingValue
3193
+ }
3194
+
2384
3195
  func triggerMentionSuggestionTapForTesting(at index: Int) {
2385
3196
  accessoryToolbar.triggerMentionSuggestionTapForTesting(at: index)
2386
3197
  }
@@ -2398,6 +3209,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2398
3209
  }
2399
3210
 
2400
3211
  private func updateAccessoryToolbarVisibility() {
3212
+ guard prepareForInputAccessoryMutationOrRetry(.updateAccessoryToolbarVisibility) else { return }
2401
3213
  refreshSystemAssistantToolbarIfNeeded()
2402
3214
  let nextAccessoryView: UIView?
2403
3215
  if showsToolbar &&
@@ -2417,6 +3229,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2417
3229
  richTextView.textView.reloadInputViews()
2418
3230
  }
2419
3231
  }
3232
+ markAccessoryMutationSucceeded(.updateAccessoryToolbarVisibility)
2420
3233
  }
2421
3234
 
2422
3235
  private var shouldUseSystemAssistantToolbar: Bool {