@openeditor/react-native-prose-editor 0.0.7 → 0.0.8

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.
Files changed (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1393 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +30 -45
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +333 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -1,687 +0,0 @@
1
- import XCTest
2
-
3
- private struct ApplyUpdateTraceStats {
4
- let name: String
5
- let traces: [EditorTextView.ApplyUpdateTrace]
6
-
7
- private func average(_ selector: (EditorTextView.ApplyUpdateTrace) -> UInt64) -> Double {
8
- guard !traces.isEmpty else { return 0 }
9
- return Double(traces.map(selector).reduce(0, +)) / Double(traces.count) / 1_000_000.0
10
- }
11
-
12
- func summaryString(tag: String = "NativePerformanceTests") -> String {
13
- let averageReplaceUtf16 = traces.isEmpty
14
- ? 0
15
- : traces.map(\.applyRenderReplaceUtf16Length).reduce(0, +) / traces.count
16
- let averageReplacementUtf16 = traces.isEmpty
17
- ? 0
18
- : traces.map(\.applyRenderReplacementUtf16Length).reduce(0, +) / traces.count
19
- return "[\(tag)] \(name) avgMs={parse=\(String(format: "%.3f", average { $0.parseNanos })), resolveBlocks=\(String(format: "%.3f", average { $0.resolveRenderBlocksNanos })), patchEligibility=\(String(format: "%.3f", average { $0.patchEligibilityNanos })), patchTrim=\(String(format: "%.3f", average { $0.patchTrimNanos })), patchMetadata=\(String(format: "%.3f", average { $0.patchMetadataNanos })), buildRender=\(String(format: "%.3f", average { $0.buildRenderNanos })), applyRender=\(String(format: "%.3f", average { $0.applyRenderNanos })), applyRenderTextMutation=\(String(format: "%.3f", average { $0.applyRenderTextMutationNanos })), applyRenderBeginEditing=\(String(format: "%.3f", average { $0.applyRenderBeginEditingNanos })), applyRenderEndEditing=\(String(format: "%.3f", average { $0.applyRenderEndEditingNanos })), applyRenderStringMutation=\(String(format: "%.3f", average { $0.applyRenderStringMutationNanos })), applyRenderAttributeMutation=\(String(format: "%.3f", average { $0.applyRenderAttributeMutationNanos })), applyRenderAuthorizedText=\(String(format: "%.3f", average { $0.applyRenderAuthorizedTextNanos })), applyRenderCacheInvalidation=\(String(format: "%.3f", average { $0.applyRenderCacheInvalidationNanos })), selection=\(String(format: "%.3f", average { $0.selectionNanos })), selectionResolve=\(String(format: "%.3f", average { $0.selectionResolveNanos })), selectionAssignment=\(String(format: "%.3f", average { $0.selectionAssignmentNanos })), selectionChrome=\(String(format: "%.3f", average { $0.selectionChromeNanos })), postApply=\(String(format: "%.3f", average { $0.postApplyNanos })), postApplyTypingAttributes=\(String(format: "%.3f", average { $0.postApplyTypingAttributesNanos })), postApplyHeightNotify=\(String(format: "%.3f", average { $0.postApplyHeightNotifyNanos })), postApplyHeightNotifyMeasure=\(String(format: "%.3f", average { $0.postApplyHeightNotifyMeasureNanos })), postApplyHeightNotifyCallback=\(String(format: "%.3f", average { $0.postApplyHeightNotifyCallbackNanos })), postApplyHeightNotifyEnsureLayout=\(String(format: "%.3f", average { $0.postApplyHeightNotifyEnsureLayoutNanos })), postApplyHeightNotifyUsedRect=\(String(format: "%.3f", average { $0.postApplyHeightNotifyUsedRectNanos })), postApplyHeightNotifyContentSize=\(String(format: "%.3f", average { $0.postApplyHeightNotifyContentSizeNanos })), postApplyHeightNotifySizeThatFits=\(String(format: "%.3f", average { $0.postApplyHeightNotifySizeThatFitsNanos })), postApplySelectionOrContent=\(String(format: "%.3f", average { $0.postApplySelectionOrContentCallbackNanos })), total=\(String(format: "%.3f", average { $0.totalNanos }))} patchUsage=\(traces.filter { $0.usedPatch }.count)/\(traces.count) smallPatchMutationUsage=\(traces.filter { $0.usedSmallPatchTextMutation }.count)/\(traces.count) avgPatchUtf16={replace=\(averageReplaceUtf16), replacement=\(averageReplacementUtf16)}"
20
- }
21
- }
22
-
23
- private struct HostedLayoutTraceStats {
24
- let name: String
25
- let traces: [RichTextEditorView.HostedLayoutTrace]
26
-
27
- private func average(
28
- _ selector: (RichTextEditorView.HostedLayoutTrace) -> UInt64
29
- ) -> Double {
30
- guard !traces.isEmpty else { return 0 }
31
- return Double(traces.map(selector).reduce(0, +)) / Double(traces.count) / 1_000_000.0
32
- }
33
-
34
- private func averageCount(
35
- _ selector: (RichTextEditorView.HostedLayoutTrace) -> Int
36
- ) -> Double {
37
- guard !traces.isEmpty else { return 0 }
38
- return Double(traces.map(selector).reduce(0, +)) / Double(traces.count)
39
- }
40
-
41
- func summaryString(tag: String = "NativePerformanceTests") -> String {
42
- "[\(tag)] \(name) avgMs={intrinsicContentSize=\(String(format: "%.3f", average { $0.intrinsicContentSizeNanos })), measuredEditorHeight=\(String(format: "%.3f", average { $0.measuredEditorHeightNanos })), layoutSubviews=\(String(format: "%.3f", average { $0.layoutSubviewsNanos })), refreshOverlays=\(String(format: "%.3f", average { $0.refreshOverlaysNanos })), onHeightMayChange=\(String(format: "%.3f", average { $0.onHeightMayChangeNanos }))} avgCount={intrinsicContentSize=\(String(format: "%.2f", averageCount { $0.intrinsicContentSizeCount })), measuredEditorHeight=\(String(format: "%.2f", averageCount { $0.measuredEditorHeightCount })), layoutSubviews=\(String(format: "%.2f", averageCount { $0.layoutSubviewsCount })), refreshOverlays=\(String(format: "%.2f", averageCount { $0.refreshOverlaysCount })), overlayScheduleRequest=\(String(format: "%.2f", averageCount { $0.overlayScheduleRequestCount })), overlayScheduleExecute=\(String(format: "%.2f", averageCount { $0.overlayScheduleExecuteCount })), overlayScheduleSkip=\(String(format: "%.2f", averageCount { $0.overlayScheduleSkipCount })), onHeightMayChange=\(String(format: "%.2f", averageCount { $0.onHeightMayChangeCount }))}"
43
- }
44
- }
45
-
46
- @MainActor
47
- final class NativePerformanceTests: XCTestCase {
48
- private let baseFont = UIFont.systemFont(ofSize: 16)
49
- private let textColor = UIColor.black
50
-
51
- func testPerformance_renderBridgeLargeDocument() {
52
- let renderJSON = NativePerformanceFixtureFactory.largeRenderJSON()
53
- let options = measureOptions()
54
-
55
- measure(metrics: [XCTClockMetric()], options: options) {
56
- autoreleasepool {
57
- let attributed = RenderBridge.renderElements(
58
- fromJSON: renderJSON,
59
- baseFont: baseFont,
60
- textColor: textColor
61
- )
62
-
63
- XCTAssertGreaterThan(attributed.length, 0)
64
- _ = attributed.string.utf16.count
65
- if attributed.length > 0 {
66
- _ = attributed.attributes(at: min(1, attributed.length - 1), effectiveRange: nil)
67
- }
68
- }
69
- }
70
- }
71
-
72
- func testPerformance_applyUpdateJSONLargeDocument() {
73
- let editorId = editorCreate(configJson: "{}")
74
- defer { editorDestroy(id: editorId) }
75
-
76
- let updateJSON = NativePerformanceFixtureFactory.loadLargeDocument(into: editorId)
77
- let textView = EditorTextView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
78
- textView.captureApplyUpdateTraceForTesting = true
79
- textView.bindEditor(id: editorId)
80
- var traceSamples: [EditorTextView.ApplyUpdateTrace] = []
81
-
82
- // Warm the text system before measuring steady-state apply cost.
83
- textView.applyUpdateJSON(updateJSON, notifyDelegate: false)
84
- textView.layoutIfNeeded()
85
-
86
- let options = measureOptions()
87
- measure(metrics: [XCTClockMetric()], options: options) {
88
- autoreleasepool {
89
- textView.applyUpdateJSON(updateJSON, notifyDelegate: false)
90
- textView.layoutIfNeeded()
91
- if let trace = textView.lastApplyUpdateTrace() {
92
- traceSamples.append(trace)
93
- }
94
- XCTAssertFalse(textView.text.isEmpty)
95
- _ = textView.attributedText.length
96
- }
97
- }
98
- print(
99
- ApplyUpdateTraceStats(
100
- name: "applyUpdateJSONLargeDocument.breakdown",
101
- traces: traceSamples
102
- ).summaryString()
103
- )
104
- }
105
-
106
- func testPerformance_typingRoundTripLargeDocument() {
107
- let editorId = editorCreate(configJson: "{}")
108
- defer { editorDestroy(id: editorId) }
109
-
110
- _ = NativePerformanceFixtureFactory.loadLargeDocument(into: editorId)
111
- let textView = EditorTextView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
112
- textView.bindEditor(id: editorId)
113
- textView.layoutIfNeeded()
114
-
115
- let typingOffset = NativePerformanceFixtureFactory.typingCursorOffset(in: textView)
116
- setSelection(in: textView, utf16Range: NSRange(location: typingOffset, length: 0))
117
-
118
- let options = measureOptions()
119
- measure(metrics: [XCTClockMetric()], options: options) {
120
- autoreleasepool {
121
- setSelection(in: textView, utf16Range: NSRange(location: typingOffset, length: 0))
122
- textView.insertText("!")
123
- textView.deleteBackward()
124
- XCTAssertFalse(textView.text.isEmpty)
125
- XCTAssertNotNil(textView.selectedTextRange)
126
- }
127
- }
128
- }
129
-
130
- func testPerformance_paragraphSplitRoundTripLargeDocument() {
131
- let options = measureOptions()
132
- let sessions = NativePerformanceFixtureFactory.paragraphSplitSessions(
133
- count: max(options.iterationCount + 4, 12)
134
- )
135
- defer {
136
- for session in sessions {
137
- editorDestroy(id: session.editorId)
138
- }
139
- }
140
-
141
- var remainingSessionIndices = Array(sessions.indices)
142
- var traceSamples: [EditorTextView.ApplyUpdateTrace] = []
143
-
144
- measure(metrics: [XCTClockMetric()], options: options) {
145
- autoreleasepool {
146
- guard let sessionIndex = remainingSessionIndices.first else {
147
- XCTFail("expected prebuilt paragraph split sessions")
148
- return
149
- }
150
- remainingSessionIndices.removeFirst()
151
-
152
- let session = sessions[sessionIndex]
153
- setSelection(in: session.textView, utf16Range: NSRange(location: session.splitOffset, length: 0))
154
- session.textView.insertText("\n")
155
- session.textView.layoutIfNeeded()
156
-
157
- if let trace = session.textView.lastApplyUpdateTrace() {
158
- traceSamples.append(trace)
159
- }
160
- XCTAssertGreaterThan(session.textView.attributedText.length, session.initialTextLength)
161
- XCTAssertTrue(session.textView.lastRenderAppliedPatch())
162
- XCTAssertNotNil(session.textView.selectedTextRange)
163
- }
164
- }
165
- print(
166
- ApplyUpdateTraceStats(
167
- name: "paragraphSplitRoundTripLargeDocument.breakdown",
168
- traces: traceSamples
169
- ).summaryString()
170
- )
171
- }
172
-
173
- func testPerformance_paragraphSplitRoundTripLargeDocument_autoGrow() {
174
- let options = measureOptions()
175
- let sessions = NativePerformanceFixtureFactory.paragraphSplitSessions(
176
- count: max(options.iterationCount + 4, 12),
177
- autoGrow: true
178
- )
179
- defer {
180
- for session in sessions {
181
- editorDestroy(id: session.editorId)
182
- }
183
- }
184
-
185
- var remainingSessionIndices = Array(sessions.indices)
186
- var traceSamples: [EditorTextView.ApplyUpdateTrace] = []
187
-
188
- measure(metrics: [XCTClockMetric()], options: options) {
189
- autoreleasepool {
190
- guard let sessionIndex = remainingSessionIndices.first else {
191
- XCTFail("expected prebuilt paragraph split sessions")
192
- return
193
- }
194
- remainingSessionIndices.removeFirst()
195
-
196
- let session = sessions[sessionIndex]
197
- setSelection(in: session.textView, utf16Range: NSRange(location: session.splitOffset, length: 0))
198
- session.textView.insertText("\n")
199
- session.textView.layoutIfNeeded()
200
-
201
- if let trace = session.textView.lastApplyUpdateTrace() {
202
- traceSamples.append(trace)
203
- }
204
- XCTAssertGreaterThan(session.textView.attributedText.length, session.initialTextLength)
205
- XCTAssertTrue(session.textView.lastRenderAppliedPatch())
206
- XCTAssertNotNil(session.textView.selectedTextRange)
207
- }
208
- }
209
- print(
210
- ApplyUpdateTraceStats(
211
- name: "paragraphSplitRoundTripLargeDocument.autoGrow.breakdown",
212
- traces: traceSamples
213
- ).summaryString()
214
- )
215
- }
216
-
217
- func testPerformance_paragraphSplitRoundTripLargeDocument_autoGrowHostedView() {
218
- let options = measureOptions()
219
- let sessions = NativePerformanceFixtureFactory.hostedParagraphSplitSessions(
220
- count: max(options.iterationCount + 4, 12)
221
- )
222
- defer {
223
- for session in sessions {
224
- session.view.removeFromSuperview()
225
- session.window.isHidden = true
226
- editorDestroy(id: session.editorId)
227
- }
228
- }
229
-
230
- var remainingSessionIndices = Array(sessions.indices)
231
- var traceSamples: [EditorTextView.ApplyUpdateTrace] = []
232
- var hostedLayoutTraceSamples: [RichTextEditorView.HostedLayoutTrace] = []
233
-
234
- measure(metrics: [XCTClockMetric()], options: options) {
235
- autoreleasepool {
236
- guard let sessionIndex = remainingSessionIndices.first else {
237
- XCTFail("expected prebuilt hosted paragraph split sessions")
238
- return
239
- }
240
- remainingSessionIndices.removeFirst()
241
-
242
- let session = sessions[sessionIndex]
243
- session.view.captureHostedLayoutTraceForTesting = true
244
- session.view.resetHostedLayoutTraceForTesting()
245
- setSelection(in: session.view.textView, utf16Range: NSRange(location: session.splitOffset, length: 0))
246
- session.view.textView.insertText("\n")
247
- flushMainQueue()
248
-
249
- let measuredHeight = ceil(session.view.intrinsicContentSize.height)
250
- session.view.frame.size.height = measuredHeight
251
- session.view.layoutIfNeeded()
252
-
253
- if let trace = session.view.textView.lastApplyUpdateTrace() {
254
- traceSamples.append(trace)
255
- }
256
- hostedLayoutTraceSamples.append(session.view.lastHostedLayoutTraceForTesting())
257
- XCTAssertGreaterThan(session.view.textView.attributedText.length, session.initialTextLength)
258
- XCTAssertTrue(session.view.textView.lastRenderAppliedPatch())
259
- XCTAssertGreaterThan(measuredHeight, 0)
260
- }
261
- }
262
- print(
263
- ApplyUpdateTraceStats(
264
- name: "paragraphSplitRoundTripLargeDocument.autoGrowHostedView.breakdown",
265
- traces: traceSamples
266
- ).summaryString()
267
- )
268
- print(
269
- HostedLayoutTraceStats(
270
- name: "paragraphSplitRoundTripLargeDocument.autoGrowHostedView.hostedLayout",
271
- traces: hostedLayoutTraceSamples
272
- ).summaryString()
273
- )
274
- }
275
-
276
- func testPerformance_selectionScrubLargeDocument() {
277
- let editorId = editorCreate(configJson: "{}")
278
- defer { editorDestroy(id: editorId) }
279
-
280
- _ = NativePerformanceFixtureFactory.loadLargeDocument(into: editorId)
281
- let textView = EditorTextView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
282
- textView.bindEditor(id: editorId)
283
- textView.layoutIfNeeded()
284
-
285
- let scrubOffsets = NativePerformanceFixtureFactory.selectionScrubOffsets(in: textView, points: 48)
286
- let options = measureOptions()
287
-
288
- measure(metrics: [XCTClockMetric()], options: options) {
289
- autoreleasepool {
290
- for offset in scrubOffsets {
291
- setSelection(in: textView, utf16Range: NSRange(location: offset, length: 0))
292
- }
293
-
294
- let finalOffset = textView.offset(
295
- from: textView.beginningOfDocument,
296
- to: textView.selectedTextRange?.start ?? textView.endOfDocument
297
- )
298
- XCTAssertEqual(finalOffset, scrubOffsets.last ?? 0)
299
- }
300
- }
301
- }
302
-
303
- func testPerformance_remoteSelectionOverlayRefreshMultiPeerLargeDocument() {
304
- let editorId = editorCreate(configJson: "{}")
305
- defer { editorDestroy(id: editorId) }
306
-
307
- let updateJSON = NativePerformanceFixtureFactory.loadLargeDocument(into: editorId)
308
- let view = RichTextEditorView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
309
- view.editorId = editorId
310
- view.textView.applyUpdateJSON(updateJSON, notifyDelegate: false)
311
- view.layoutIfNeeded()
312
-
313
- let selections = NativePerformanceFixtureFactory.remoteSelections(
314
- editorId: editorId,
315
- peerCount: 24,
316
- selectionWidth: 24
317
- )
318
- view.setRemoteSelections(selections)
319
- view.layoutIfNeeded()
320
-
321
- let options = measureOptions()
322
- measure(metrics: [XCTClockMetric()], options: options) {
323
- autoreleasepool {
324
- view.setRemoteSelections(selections)
325
- view.layoutIfNeeded()
326
- XCTAssertFalse(view.remoteSelectionOverlaySubviewsForTesting().isEmpty)
327
- XCTAssertGreaterThanOrEqual(view.remoteSelectionOverlaySubviewsForTesting().count, selections.count)
328
- }
329
- }
330
- }
331
-
332
- private func measureOptions() -> XCTMeasureOptions {
333
- let options = XCTMeasureOptions()
334
- options.iterationCount = 5
335
- return options
336
- }
337
- }
338
-
339
- private enum NativePerformanceFixtureFactory {
340
- private static let blockCount = 96
341
- private static let paragraphCharacterCount = 180
342
-
343
- struct ParagraphSplitSession {
344
- let editorId: UInt64
345
- let textView: EditorTextView
346
- let splitOffset: Int
347
- let initialTextLength: Int
348
- }
349
-
350
- struct HostedParagraphSplitSession {
351
- let editorId: UInt64
352
- let window: UIWindow
353
- let view: RichTextEditorView
354
- let splitOffset: Int
355
- let initialTextLength: Int
356
- }
357
-
358
- static func largeRenderJSON() -> String {
359
- let editorId = editorCreate(configJson: "{}")
360
- defer { editorDestroy(id: editorId) }
361
- return editorSetJson(id: editorId, json: largeDocumentJSONString())
362
- }
363
-
364
- static func loadLargeDocument(into editorId: UInt64) -> String {
365
- _ = editorSetJson(id: editorId, json: largeDocumentJSONString())
366
- return editorGetCurrentState(id: editorId)
367
- }
368
-
369
- static func remoteSelections(
370
- editorId: UInt64,
371
- peerCount: Int = 6,
372
- selectionWidth: Int = 0
373
- ) -> [RemoteSelectionDecoration] {
374
- let totalScalar = editorDocToScalar(id: editorId, docPos: editorDocumentContentSize(id: editorId))
375
- let upperBound = max(1, Int(totalScalar > 0 ? totalScalar - 1 : 0))
376
- let samplePoints = evenlySpacedValues(from: 1, through: upperBound, count: peerCount)
377
-
378
- return samplePoints.enumerated().map { index, scalar in
379
- let headScalar = (selectionWidth > 0 && !index.isMultiple(of: 2))
380
- ? min(upperBound, scalar + selectionWidth)
381
- : scalar
382
- let anchorDoc = editorScalarToDoc(id: editorId, scalar: UInt32(scalar))
383
- let headDoc = editorScalarToDoc(id: editorId, scalar: UInt32(headScalar))
384
- return RemoteSelectionDecoration(
385
- clientId: index + 1,
386
- anchor: anchorDoc,
387
- head: headDoc,
388
- color: indexedColor(index),
389
- name: "Peer \(index + 1)",
390
- isFocused: true
391
- )
392
- }
393
- }
394
-
395
- static func typingCursorOffset(in textView: UITextView) -> Int {
396
- selectionScrubOffsets(in: textView, points: 1).first ?? 0
397
- }
398
-
399
- static func paragraphSplitSessions(count: Int, autoGrow: Bool = false) -> [ParagraphSplitSession] {
400
- (0..<count).map { _ in
401
- let editorId = editorCreate(configJson: "{}")
402
- _ = loadLargeDocument(into: editorId)
403
-
404
- let textView = EditorTextView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
405
- textView.heightBehavior = autoGrow ? .autoGrow : .fixed
406
- textView.captureApplyUpdateTraceForTesting = true
407
- textView.bindEditor(id: editorId)
408
- textView.layoutIfNeeded()
409
-
410
- return ParagraphSplitSession(
411
- editorId: editorId,
412
- textView: textView,
413
- splitOffset: paragraphSplitCursorOffset(in: textView),
414
- initialTextLength: textView.attributedText.length
415
- )
416
- }
417
- }
418
-
419
- static func hostedParagraphSplitSessions(count: Int) -> [HostedParagraphSplitSession] {
420
- (0..<count).map { _ in
421
- let editorId = editorCreate(configJson: "{}")
422
-
423
- let view = RichTextEditorView(frame: CGRect(x: 0, y: 0, width: 390, height: 0))
424
- let window = hostEditorView(view, size: CGSize(width: 390, height: 844))
425
- view.heightBehavior = .autoGrow
426
- view.textView.captureApplyUpdateTraceForTesting = true
427
- view.editorId = editorId
428
- view.setContent(json: largeDocumentJSONString())
429
- flushMainQueue()
430
-
431
- let measuredHeight = ceil(view.intrinsicContentSize.height)
432
- view.frame.size.height = measuredHeight
433
- view.layoutIfNeeded()
434
-
435
- return HostedParagraphSplitSession(
436
- editorId: editorId,
437
- window: window,
438
- view: view,
439
- splitOffset: paragraphSplitCursorOffset(in: view.textView),
440
- initialTextLength: view.textView.attributedText.length
441
- )
442
- }
443
- }
444
-
445
- static func selectionScrubOffsets(in textView: UITextView, points: Int) -> [Int] {
446
- let candidates = visibleCharacterOffsets(in: textView.textStorage.string as NSString)
447
- guard !candidates.isEmpty else { return [0] }
448
- return evenlySpacedValues(from: 0, through: candidates.count - 1, count: points).map { candidates[$0] }
449
- }
450
-
451
- static func paragraphSplitCursorOffset(in textView: UITextView) -> Int {
452
- let text = textView.textStorage.string as NSString
453
- let firstBlockBreak = (0..<text.length).first { index in
454
- let character = text.character(at: index)
455
- return character == 0x000A || character == 0x000D
456
- }
457
-
458
- guard let firstBlockBreak else {
459
- return typingCursorOffset(in: textView)
460
- }
461
-
462
- let paragraphOffsets = visibleCharacterOffsets(in: text).filter { $0 > firstBlockBreak }
463
- guard !paragraphOffsets.isEmpty else {
464
- return typingCursorOffset(in: textView)
465
- }
466
-
467
- return paragraphOffsets[min(32, paragraphOffsets.count - 1)]
468
- }
469
-
470
- private static func largeDocumentJSONString() -> String {
471
- let jsonObject: [String: Any] = [
472
- "type": "doc",
473
- "content": largeDocumentContent(),
474
- ]
475
- let data = try! JSONSerialization.data(withJSONObject: jsonObject, options: [])
476
- return String(data: data, encoding: .utf8)!
477
- }
478
-
479
- private static func largeDocumentContent() -> [[String: Any]] {
480
- var content: [[String: Any]] = [
481
- [
482
- "type": "h1",
483
- "content": [textNode(textFragment(seed: 10_000, minCharacterCount: 40))],
484
- ],
485
- ]
486
-
487
- for index in 0..<blockCount {
488
- if index > 0 && index % 18 == 0 {
489
- content.append(["type": "horizontalRule"])
490
- }
491
-
492
- if index % 12 == 5 {
493
- content.append([
494
- "type": "blockquote",
495
- "content": [[
496
- "type": "paragraph",
497
- "content": richInlineContent(seed: index, totalCharacters: paragraphCharacterCount),
498
- ]],
499
- ])
500
- continue
501
- }
502
-
503
- if index % 9 == 3 {
504
- content.append([
505
- "type": "h2",
506
- "content": [textNode(textFragment(seed: index + 2_000, minCharacterCount: 72))],
507
- ])
508
- continue
509
- }
510
-
511
- content.append([
512
- "type": "paragraph",
513
- "content": richInlineContent(seed: index, totalCharacters: paragraphCharacterCount),
514
- ])
515
- }
516
-
517
- return content
518
- }
519
-
520
- private static func richInlineContent(seed: Int, totalCharacters: Int) -> [[String: Any]] {
521
- let text = textFragment(seed: seed, minCharacterCount: totalCharacters)
522
- let characters = Array(text)
523
- let count = characters.count
524
- let cutA = count / 4
525
- let cutB = count / 2
526
- let cutC = (count * 3) / 4
527
-
528
- let segments: [(String, [[String: Any]]?)] = [
529
- (String(characters[0..<cutA]), nil),
530
- (String(characters[cutA..<cutB]), [["type": "bold"]]),
531
- (String(characters[cutB..<cutC]), [["type": "italic"]]),
532
- (
533
- String(characters[cutC..<count]),
534
- [[
535
- "type": "link",
536
- "attrs": [
537
- "href": "https://example.com/item/\(seed)",
538
- "target": "_blank",
539
- "rel": "noopener noreferrer nofollow",
540
- "class": NSNull(),
541
- "title": NSNull(),
542
- ],
543
- ]]
544
- ),
545
- ]
546
-
547
- return segments.compactMap { text, marks in
548
- guard !text.isEmpty else { return nil }
549
- return textNode(text, marks: marks)
550
- }
551
- }
552
-
553
- private static func textNode(_ text: String, marks: [[String: Any]]? = nil) -> [String: Any] {
554
- var node: [String: Any] = [
555
- "type": "text",
556
- "text": text,
557
- ]
558
- if let marks, !marks.isEmpty {
559
- node["marks"] = marks
560
- }
561
- return node
562
- }
563
-
564
- private static func textFragment(seed: Int, minCharacterCount: Int) -> String {
565
- let words = [
566
- "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
567
- "juliet", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo",
568
- "sierra", "tango", "uniform", "victor", "whiskey", "xray", "yankee", "zulu",
569
- ]
570
-
571
- var result = ""
572
- var cursor = 0
573
- while result.count < minCharacterCount {
574
- if !result.isEmpty {
575
- result.append(" ")
576
- }
577
- result.append(words[(seed + cursor) % words.count])
578
- cursor += 1
579
- }
580
- return String(result.prefix(minCharacterCount))
581
- }
582
-
583
- private static func indexedColor(_ index: Int) -> UIColor {
584
- let colors: [UIColor] = [
585
- .systemBlue,
586
- .systemGreen,
587
- .systemOrange,
588
- .systemPink,
589
- .systemPurple,
590
- .systemTeal,
591
- ]
592
- return colors[index % colors.count]
593
- }
594
-
595
- private static func visibleCharacterOffsets(in text: NSString) -> [Int] {
596
- (0..<text.length).compactMap { index in
597
- switch text.character(at: index) {
598
- case 0xFFFC, 0x200B, 0x000A, 0x000D:
599
- return nil
600
- default:
601
- return index
602
- }
603
- }
604
- }
605
-
606
- private static func evenlySpacedValues(from start: Int, through end: Int, count: Int) -> [Int] {
607
- guard count > 1, end > start else {
608
- return [min(start, end)]
609
- }
610
-
611
- return (0..<count).map { index in
612
- start + Int((Double(end - start) * Double(index) / Double(count - 1)).rounded(.toNearestOrAwayFromZero))
613
- }
614
- }
615
-
616
- private static func editorDocumentContentSize(id: UInt64) -> UInt32 {
617
- guard let data = editorGetJson(id: id).data(using: .utf8),
618
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
619
- else {
620
- return 0
621
- }
622
- let children = json["content"] as? [[String: Any]] ?? []
623
- return children.reduce(UInt32(0)) { partial, child in
624
- partial + nodeSize(child)
625
- }
626
- }
627
-
628
- private static func nodeSize(_ node: [String: Any]) -> UInt32 {
629
- let type = node["type"] as? String ?? ""
630
- if type == "text" {
631
- let text = node["text"] as? String ?? ""
632
- return UInt32(text.count)
633
- }
634
-
635
- if isVoidNode(type) {
636
- return 1
637
- }
638
-
639
- let children = node["content"] as? [[String: Any]] ?? []
640
- let childrenSize = children.reduce(UInt32(0)) { partial, child in
641
- partial + nodeSize(child)
642
- }
643
-
644
- return 1 + childrenSize + 1
645
- }
646
-
647
- private static func isVoidNode(_ type: String) -> Bool {
648
- switch type {
649
- case "horizontalRule", "hardBreak", "image", "mention":
650
- return true
651
- default:
652
- return false
653
- }
654
- }
655
- }
656
-
657
- private func setSelection(in textView: UITextView, utf16Range: NSRange) {
658
- guard
659
- let start = textView.position(from: textView.beginningOfDocument, offset: utf16Range.location),
660
- let end = textView.position(from: start, offset: utf16Range.length),
661
- let range = textView.textRange(from: start, to: end)
662
- else {
663
- XCTFail("expected selection range \(utf16Range)")
664
- return
665
- }
666
-
667
- textView.selectedTextRange = range
668
- }
669
-
670
- private func hostEditorView(_ view: RichTextEditorView, size: CGSize) -> UIWindow {
671
- let window = UIWindow(frame: CGRect(origin: .zero, size: size))
672
- let viewController = UIViewController()
673
- window.rootViewController = viewController
674
- window.makeKeyAndVisible()
675
- view.frame = CGRect(origin: .zero, size: size)
676
- viewController.view.addSubview(view)
677
- view.layoutIfNeeded()
678
- return window
679
- }
680
-
681
- private func flushMainQueue() {
682
- let expectation = XCTestExpectation(description: "flush main queue")
683
- DispatchQueue.main.async {
684
- expectation.fulfill()
685
- }
686
- XCTWaiter().wait(for: [expectation], timeout: 1.0)
687
- }