@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.
- package/android/build.gradle +35 -0
- package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
- package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
- package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
- package/dist/EditorToolbar.d.ts +1 -1
- package/dist/EditorToolbar.js +19 -0
- package/dist/NativeEditorBridge.d.ts +62 -35
- package/dist/NativeEditorBridge.js +122 -79
- package/dist/NativeProseViewer.d.ts +13 -34
- package/dist/NativeProseViewer.js +40 -465
- package/dist/NativeRichTextEditor.d.ts +10 -1
- package/dist/NativeRichTextEditor.js +72 -34
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/schemas.d.ts +11 -0
- package/dist/schemas.js +149 -0
- package/dist/useNativeEditor.d.ts +3 -3
- package/dist/useNativeEditor.js +3 -3
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/Generated_editor_core.swift +97 -2
- package/ios/NativeBlockEditorSurface.swift +1393 -0
- package/ios/NativeEditorExpoView.swift +3 -3
- package/ios/NativeEditorModule.swift +30 -45
- package/ios/NativeInputSupport.swift +396 -0
- package/ios/PositionBridge.swift +4 -4
- package/ios/RichTextEditorView.swift +333 -786
- package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
- package/package.json +1 -1
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
- package/android/src/androidTest/AndroidManifest.xml +0 -8
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
- package/ios/EditorLayoutManager.swift +0 -774
- package/ios/NativeProseViewerExpoView.swift +0 -276
- package/ios/RenderBridge.swift +0 -1708
- package/ios/Tests/NativePerformanceTests.swift +0 -687
- package/ios/Tests/PositionBridgeTests.swift +0 -706
- package/ios/Tests/RenderBridgeTests.swift +0 -2236
- package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
|
@@ -1,706 +0,0 @@
|
|
|
1
|
-
import XCTest
|
|
2
|
-
|
|
3
|
-
// MARK: - PositionBridge Tests
|
|
4
|
-
|
|
5
|
-
final class PositionBridgeTests: XCTestCase {
|
|
6
|
-
|
|
7
|
-
private func makeTextView(with attributedText: NSAttributedString) -> UITextView {
|
|
8
|
-
let layoutManager = EditorLayoutManager()
|
|
9
|
-
let textContainer = NSTextContainer(size: .zero)
|
|
10
|
-
let textStorage = NSTextStorage(attributedString: attributedText)
|
|
11
|
-
layoutManager.addTextContainer(textContainer)
|
|
12
|
-
textStorage.addLayoutManager(layoutManager)
|
|
13
|
-
return UITextView(frame: .zero, textContainer: textContainer)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
private func assertConversionsMatch(
|
|
17
|
-
_ patched: UITextView,
|
|
18
|
-
_ rebuilt: UITextView,
|
|
19
|
-
file: StaticString = #filePath,
|
|
20
|
-
line: UInt = #line
|
|
21
|
-
) {
|
|
22
|
-
XCTAssertEqual(patched.textStorage.length, rebuilt.textStorage.length, file: file, line: line)
|
|
23
|
-
for offset in 0...patched.textStorage.length {
|
|
24
|
-
XCTAssertEqual(
|
|
25
|
-
PositionBridge.utf16OffsetToScalar(offset, in: patched),
|
|
26
|
-
PositionBridge.utf16OffsetToScalar(offset, in: rebuilt),
|
|
27
|
-
"UTF-16 offset \(offset) should match a fresh conversion-table rebuild",
|
|
28
|
-
file: file,
|
|
29
|
-
line: line
|
|
30
|
-
)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
let maxScalar = PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: rebuilt)
|
|
34
|
-
XCTAssertEqual(
|
|
35
|
-
maxScalar,
|
|
36
|
-
PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: patched),
|
|
37
|
-
file: file,
|
|
38
|
-
line: line
|
|
39
|
-
)
|
|
40
|
-
for scalar in 0...Int(maxScalar) {
|
|
41
|
-
XCTAssertEqual(
|
|
42
|
-
PositionBridge.scalarToUtf16Offset(UInt32(scalar), in: patched),
|
|
43
|
-
PositionBridge.scalarToUtf16Offset(UInt32(scalar), in: rebuilt),
|
|
44
|
-
"Scalar \(scalar) should map to the same UTF-16 offset after cache patching",
|
|
45
|
-
file: file,
|
|
46
|
-
line: line
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// MARK: - UTF-16 -> Scalar: ASCII
|
|
52
|
-
|
|
53
|
-
/// ASCII characters are 1 UTF-16 code unit = 1 scalar each.
|
|
54
|
-
func testUtf16ToScalar_asciiOnly() {
|
|
55
|
-
let text = "Hello"
|
|
56
|
-
// "Hello" = 5 UTF-16 code units = 5 scalars
|
|
57
|
-
XCTAssertEqual(
|
|
58
|
-
PositionBridge.utf16OffsetToScalar(0, in: text), 0,
|
|
59
|
-
"Offset 0 should map to scalar 0"
|
|
60
|
-
)
|
|
61
|
-
XCTAssertEqual(
|
|
62
|
-
PositionBridge.utf16OffsetToScalar(1, in: text), 1,
|
|
63
|
-
"Offset 1 in ASCII should map to scalar 1"
|
|
64
|
-
)
|
|
65
|
-
XCTAssertEqual(
|
|
66
|
-
PositionBridge.utf16OffsetToScalar(5, in: text), 5,
|
|
67
|
-
"Offset 5 (end of 'Hello') should map to scalar 5"
|
|
68
|
-
)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/// Empty string edge case.
|
|
72
|
-
func testUtf16ToScalar_emptyString() {
|
|
73
|
-
let text = ""
|
|
74
|
-
XCTAssertEqual(
|
|
75
|
-
PositionBridge.utf16OffsetToScalar(0, in: text), 0,
|
|
76
|
-
"Empty string at offset 0 should return scalar 0"
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
func testUtf16ToScalar_listParagraphAddsVirtualMarkerScalars() {
|
|
81
|
-
let json = """
|
|
82
|
-
[
|
|
83
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
84
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
85
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
86
|
-
{"type": "textRun", "text": "Item", "marks": []},
|
|
87
|
-
{"type": "blockEnd"},
|
|
88
|
-
{"type": "blockEnd"}
|
|
89
|
-
]
|
|
90
|
-
"""
|
|
91
|
-
let attributed = RenderBridge.renderElements(
|
|
92
|
-
fromJSON: json,
|
|
93
|
-
baseFont: .systemFont(ofSize: 16),
|
|
94
|
-
textColor: .label
|
|
95
|
-
)
|
|
96
|
-
let textView = makeTextView(with: attributed)
|
|
97
|
-
|
|
98
|
-
XCTAssertEqual(PositionBridge.utf16OffsetToScalar(0, in: textView), 2)
|
|
99
|
-
XCTAssertEqual(PositionBridge.utf16OffsetToScalar(4, in: textView), 6)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
func testScalarToUtf16_listMarkerPrefixMapsToParagraphStart() {
|
|
103
|
-
let json = """
|
|
104
|
-
[
|
|
105
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
106
|
-
"listContext": {"ordered": true, "index": 12, "total": 1, "start": 12, "isFirst": true, "isLast": true}},
|
|
107
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
108
|
-
{"type": "textRun", "text": "Item", "marks": []},
|
|
109
|
-
{"type": "blockEnd"},
|
|
110
|
-
{"type": "blockEnd"}
|
|
111
|
-
]
|
|
112
|
-
"""
|
|
113
|
-
let attributed = RenderBridge.renderElements(
|
|
114
|
-
fromJSON: json,
|
|
115
|
-
baseFont: .systemFont(ofSize: 16),
|
|
116
|
-
textColor: .label
|
|
117
|
-
)
|
|
118
|
-
let textView = makeTextView(with: attributed)
|
|
119
|
-
|
|
120
|
-
XCTAssertEqual(PositionBridge.scalarToUtf16Offset(1, in: textView), 0)
|
|
121
|
-
XCTAssertEqual(PositionBridge.scalarToUtf16Offset(4, in: textView), 0)
|
|
122
|
-
XCTAssertEqual(PositionBridge.scalarToUtf16Offset(5, in: textView), 1)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
func testUtf16ToScalar_secondParagraphInListItemDoesNotAddAnotherVirtualMarker() {
|
|
126
|
-
let json = """
|
|
127
|
-
[
|
|
128
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
129
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
130
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
131
|
-
{"type": "textRun", "text": "A", "marks": []},
|
|
132
|
-
{"type": "blockEnd"},
|
|
133
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
134
|
-
{"type": "textRun", "text": "\\u200B", "marks": []},
|
|
135
|
-
{"type": "blockEnd"},
|
|
136
|
-
{"type": "blockEnd"}
|
|
137
|
-
]
|
|
138
|
-
"""
|
|
139
|
-
let attributed = RenderBridge.renderElements(
|
|
140
|
-
fromJSON: json,
|
|
141
|
-
baseFont: .systemFont(ofSize: 16),
|
|
142
|
-
textColor: .label
|
|
143
|
-
)
|
|
144
|
-
let textView = makeTextView(with: attributed)
|
|
145
|
-
|
|
146
|
-
XCTAssertEqual(
|
|
147
|
-
PositionBridge.utf16OffsetToScalar(2, in: textView), 4,
|
|
148
|
-
"Only the first paragraph in a list item should contribute virtual marker scalars"
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
func testUtf16ToScalar_hardBreakInsideListParagraphDoesNotAddAnotherVirtualMarker() {
|
|
153
|
-
let json = """
|
|
154
|
-
[
|
|
155
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
156
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
157
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
158
|
-
{"type": "textRun", "text": "A", "marks": []},
|
|
159
|
-
{"type": "voidInline", "nodeType": "hardBreak", "docPos": 4},
|
|
160
|
-
{"type": "textRun", "text": "B", "marks": []},
|
|
161
|
-
{"type": "blockEnd"},
|
|
162
|
-
{"type": "blockEnd"}
|
|
163
|
-
]
|
|
164
|
-
"""
|
|
165
|
-
let attributed = RenderBridge.renderElements(
|
|
166
|
-
fromJSON: json,
|
|
167
|
-
baseFont: .systemFont(ofSize: 16),
|
|
168
|
-
textColor: .label
|
|
169
|
-
)
|
|
170
|
-
let textView = makeTextView(with: attributed)
|
|
171
|
-
|
|
172
|
-
XCTAssertEqual(
|
|
173
|
-
PositionBridge.utf16OffsetToScalar(2, in: textView), 4,
|
|
174
|
-
"A hardBreak inside a list paragraph should not create a second virtual marker"
|
|
175
|
-
)
|
|
176
|
-
XCTAssertTrue(
|
|
177
|
-
EditorLayoutManager.isParagraphStartCreatedByHardBreak(2, in: textView.textStorage),
|
|
178
|
-
"The visual line after a hardBreak should be recognized as a synthetic paragraph start"
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
func testUtf16ToScalar_taskListParagraphContributesVirtualCheckboxMarker() {
|
|
183
|
-
let json = """
|
|
184
|
-
[
|
|
185
|
-
{"type": "blockStart", "nodeType": "taskItem", "depth": 1,
|
|
186
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true, "kind": "task", "checked": false}},
|
|
187
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
188
|
-
{"type": "textRun", "text": "A", "marks": []},
|
|
189
|
-
{"type": "blockEnd"},
|
|
190
|
-
{"type": "blockEnd"}
|
|
191
|
-
]
|
|
192
|
-
"""
|
|
193
|
-
let attributed = RenderBridge.renderElements(
|
|
194
|
-
fromJSON: json,
|
|
195
|
-
baseFont: .systemFont(ofSize: 16),
|
|
196
|
-
textColor: .label
|
|
197
|
-
)
|
|
198
|
-
let textView = makeTextView(with: attributed)
|
|
199
|
-
|
|
200
|
-
XCTAssertEqual(
|
|
201
|
-
PositionBridge.utf16OffsetToScalar(0, in: textView), 2,
|
|
202
|
-
"A task item paragraph should contribute its checkbox marker as virtual scalar prefix"
|
|
203
|
-
)
|
|
204
|
-
XCTAssertEqual(
|
|
205
|
-
PositionBridge.utf16OffsetToScalar(1, in: textView), 3,
|
|
206
|
-
"Text inside a task item should be offset by the checkbox marker length"
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
func testUtf16ToScalar_trailingHardBreakPlaceholderDoesNotCountAsScalar() {
|
|
211
|
-
let json = """
|
|
212
|
-
[
|
|
213
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
214
|
-
{"type": "textRun", "text": "A", "marks": []},
|
|
215
|
-
{"type": "voidInline", "nodeType": "hardBreak", "docPos": 2},
|
|
216
|
-
{"type": "blockEnd"}
|
|
217
|
-
]
|
|
218
|
-
"""
|
|
219
|
-
let attributed = RenderBridge.renderElements(
|
|
220
|
-
fromJSON: json,
|
|
221
|
-
baseFont: .systemFont(ofSize: 16),
|
|
222
|
-
textColor: .label
|
|
223
|
-
)
|
|
224
|
-
let textView = makeTextView(with: attributed)
|
|
225
|
-
|
|
226
|
-
XCTAssertEqual(textView.text, "A\n\u{200B}")
|
|
227
|
-
XCTAssertEqual(PositionBridge.utf16OffsetToScalar(2, in: textView), 2)
|
|
228
|
-
XCTAssertEqual(
|
|
229
|
-
PositionBridge.utf16OffsetToScalar(3, in: textView),
|
|
230
|
-
2,
|
|
231
|
-
"The synthetic trailing hardBreak placeholder should not count as content"
|
|
232
|
-
)
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
func testApplyAttributedPatch_handlesSyntheticPlaceholderAdjustment() {
|
|
236
|
-
let initialJSON = """
|
|
237
|
-
[
|
|
238
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
239
|
-
{"type": "textRun", "text": "A", "marks": []},
|
|
240
|
-
{"type": "blockEnd"}
|
|
241
|
-
]
|
|
242
|
-
"""
|
|
243
|
-
let replacementJSON = """
|
|
244
|
-
[
|
|
245
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
246
|
-
{"type": "textRun", "text": "A", "marks": []},
|
|
247
|
-
{"type": "voidInline", "nodeType": "hardBreak", "docPos": 2},
|
|
248
|
-
{"type": "blockEnd"}
|
|
249
|
-
]
|
|
250
|
-
"""
|
|
251
|
-
|
|
252
|
-
let initial = RenderBridge.renderElements(
|
|
253
|
-
fromJSON: initialJSON,
|
|
254
|
-
baseFont: .systemFont(ofSize: 16),
|
|
255
|
-
textColor: .label
|
|
256
|
-
)
|
|
257
|
-
let replacement = RenderBridge.renderElements(
|
|
258
|
-
fromJSON: replacementJSON,
|
|
259
|
-
baseFont: .systemFont(ofSize: 16),
|
|
260
|
-
textColor: .label
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
let patched = makeTextView(with: initial)
|
|
264
|
-
_ = PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: patched)
|
|
265
|
-
let replaceRange = NSRange(location: 0, length: patched.textStorage.length)
|
|
266
|
-
patched.textStorage.beginEditing()
|
|
267
|
-
patched.textStorage.replaceCharacters(in: replaceRange, with: replacement)
|
|
268
|
-
patched.textStorage.endEditing()
|
|
269
|
-
|
|
270
|
-
XCTAssertTrue(
|
|
271
|
-
PositionBridge.applyAttributedPatchIfPossible(
|
|
272
|
-
for: patched,
|
|
273
|
-
replaceRange: replaceRange,
|
|
274
|
-
replacement: replacement
|
|
275
|
-
)
|
|
276
|
-
)
|
|
277
|
-
|
|
278
|
-
let rebuilt = makeTextView(with: replacement)
|
|
279
|
-
assertConversionsMatch(patched, rebuilt)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
func testApplyAttributedPatch_handlesVirtualListMarkerAdjustment() {
|
|
283
|
-
let initialJSON = """
|
|
284
|
-
[
|
|
285
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
286
|
-
{"type": "textRun", "text": "Item", "marks": []},
|
|
287
|
-
{"type": "blockEnd"}
|
|
288
|
-
]
|
|
289
|
-
"""
|
|
290
|
-
let replacementJSON = """
|
|
291
|
-
[
|
|
292
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
293
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
294
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
295
|
-
{"type": "textRun", "text": "Item", "marks": []},
|
|
296
|
-
{"type": "blockEnd"},
|
|
297
|
-
{"type": "blockEnd"}
|
|
298
|
-
]
|
|
299
|
-
"""
|
|
300
|
-
|
|
301
|
-
let initial = RenderBridge.renderElements(
|
|
302
|
-
fromJSON: initialJSON,
|
|
303
|
-
baseFont: .systemFont(ofSize: 16),
|
|
304
|
-
textColor: .label
|
|
305
|
-
)
|
|
306
|
-
let replacement = RenderBridge.renderElements(
|
|
307
|
-
fromJSON: replacementJSON,
|
|
308
|
-
baseFont: .systemFont(ofSize: 16),
|
|
309
|
-
textColor: .label
|
|
310
|
-
)
|
|
311
|
-
|
|
312
|
-
let patched = makeTextView(with: initial)
|
|
313
|
-
_ = PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: patched)
|
|
314
|
-
let replaceRange = NSRange(location: 0, length: patched.textStorage.length)
|
|
315
|
-
patched.textStorage.beginEditing()
|
|
316
|
-
patched.textStorage.replaceCharacters(in: replaceRange, with: replacement)
|
|
317
|
-
patched.textStorage.endEditing()
|
|
318
|
-
|
|
319
|
-
XCTAssertTrue(
|
|
320
|
-
PositionBridge.applyAttributedPatchIfPossible(
|
|
321
|
-
for: patched,
|
|
322
|
-
replaceRange: replaceRange,
|
|
323
|
-
replacement: replacement
|
|
324
|
-
)
|
|
325
|
-
)
|
|
326
|
-
|
|
327
|
-
let rebuilt = makeTextView(with: replacement)
|
|
328
|
-
assertConversionsMatch(patched, rebuilt)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
func testApplyPlainTextPatch_handlesPartialPlainTextReplacement() {
|
|
332
|
-
let initial = NSAttributedString(
|
|
333
|
-
string: "Alpha Beta Gamma",
|
|
334
|
-
attributes: [.font: UIFont.systemFont(ofSize: 16)]
|
|
335
|
-
)
|
|
336
|
-
let replacementText = "Beta\n"
|
|
337
|
-
let replaceRange = NSRange(location: 6, length: 4)
|
|
338
|
-
|
|
339
|
-
let patched = makeTextView(with: initial)
|
|
340
|
-
_ = PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: patched)
|
|
341
|
-
patched.textStorage.beginEditing()
|
|
342
|
-
patched.textStorage.replaceCharacters(
|
|
343
|
-
in: replaceRange,
|
|
344
|
-
with: NSAttributedString(
|
|
345
|
-
string: replacementText,
|
|
346
|
-
attributes: [.font: UIFont.systemFont(ofSize: 16)]
|
|
347
|
-
)
|
|
348
|
-
)
|
|
349
|
-
patched.textStorage.endEditing()
|
|
350
|
-
|
|
351
|
-
XCTAssertTrue(
|
|
352
|
-
PositionBridge.applyPlainTextPatchIfPossible(
|
|
353
|
-
for: patched,
|
|
354
|
-
replaceRange: replaceRange,
|
|
355
|
-
replacementText: replacementText
|
|
356
|
-
)
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
let rebuilt = makeTextView(
|
|
360
|
-
with: NSAttributedString(
|
|
361
|
-
string: "Alpha Beta\n Gamma",
|
|
362
|
-
attributes: [.font: UIFont.systemFont(ofSize: 16)]
|
|
363
|
-
)
|
|
364
|
-
)
|
|
365
|
-
assertConversionsMatch(patched, rebuilt)
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// MARK: - UTF-16 -> Scalar: BMP Emoji (1 scalar, 1 UTF-16)
|
|
369
|
-
|
|
370
|
-
/// Simple emoji in the BMP (e.g. U+263A, white smiley face) are 1 UTF-16 code unit.
|
|
371
|
-
func testUtf16ToScalar_bmpCharacters() {
|
|
372
|
-
// U+00E9 (e-acute) is 1 UTF-16 code unit, 1 scalar
|
|
373
|
-
let text = "caf\u{00E9}" // "cafe" with accent
|
|
374
|
-
XCTAssertEqual(
|
|
375
|
-
PositionBridge.utf16OffsetToScalar(4, in: text), 4,
|
|
376
|
-
"BMP character: UTF-16 offset 4 should be scalar 4"
|
|
377
|
-
)
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// MARK: - UTF-16 -> Scalar: Surrogate Pairs (1 scalar, 2 UTF-16)
|
|
381
|
-
|
|
382
|
-
/// Characters above U+FFFF (supplementary plane) are encoded as
|
|
383
|
-
/// surrogate pairs in UTF-16: 2 code units per scalar.
|
|
384
|
-
func testUtf16ToScalar_surrogatePair() {
|
|
385
|
-
// U+1F600 (grinning face) = 2 UTF-16 code units, 1 scalar
|
|
386
|
-
let text = "A\u{1F600}B"
|
|
387
|
-
// UTF-16: A(1) + U+1F600(2) + B(1) = 4 code units
|
|
388
|
-
// Scalars: A(1) + U+1F600(1) + B(1) = 3 scalars
|
|
389
|
-
|
|
390
|
-
XCTAssertEqual(
|
|
391
|
-
PositionBridge.utf16OffsetToScalar(0, in: text), 0,
|
|
392
|
-
"Before 'A'"
|
|
393
|
-
)
|
|
394
|
-
XCTAssertEqual(
|
|
395
|
-
PositionBridge.utf16OffsetToScalar(1, in: text), 1,
|
|
396
|
-
"After 'A', before emoji"
|
|
397
|
-
)
|
|
398
|
-
XCTAssertEqual(
|
|
399
|
-
PositionBridge.utf16OffsetToScalar(3, in: text), 2,
|
|
400
|
-
"After emoji (UTF-16 offset 3 = scalar 2)"
|
|
401
|
-
)
|
|
402
|
-
XCTAssertEqual(
|
|
403
|
-
PositionBridge.utf16OffsetToScalar(4, in: text), 3,
|
|
404
|
-
"After 'B' (end of string)"
|
|
405
|
-
)
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/// The family emoji π¨βπ©βπ§βπ¦ is composed of multiple scalars joined by ZWJ.
|
|
409
|
-
/// U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
|
|
410
|
-
/// = 7 scalars, 11 UTF-16 code units (4 surrogates + 3 ZWJ)
|
|
411
|
-
/// But it renders as 1 grapheme cluster.
|
|
412
|
-
func testUtf16ToScalar_familyEmoji() {
|
|
413
|
-
let text = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
|
|
414
|
-
let expectedUtf16Count = 11
|
|
415
|
-
let expectedScalarCount: UInt32 = 7
|
|
416
|
-
|
|
417
|
-
let nsString = text as NSString
|
|
418
|
-
XCTAssertEqual(
|
|
419
|
-
nsString.length, expectedUtf16Count,
|
|
420
|
-
"Family emoji should be \(expectedUtf16Count) UTF-16 code units"
|
|
421
|
-
)
|
|
422
|
-
XCTAssertEqual(
|
|
423
|
-
UInt32(text.unicodeScalars.count), expectedScalarCount,
|
|
424
|
-
"Family emoji should be \(expectedScalarCount) Unicode scalars"
|
|
425
|
-
)
|
|
426
|
-
XCTAssertEqual(
|
|
427
|
-
PositionBridge.utf16OffsetToScalar(expectedUtf16Count, in: text),
|
|
428
|
-
expectedScalarCount,
|
|
429
|
-
"Full family emoji: UTF-16 end should map to scalar end"
|
|
430
|
-
)
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// MARK: - UTF-16 -> Scalar: CJK
|
|
434
|
-
|
|
435
|
-
/// CJK characters (Chinese/Japanese/Korean) are in the BMP: 1 UTF-16 = 1 scalar.
|
|
436
|
-
func testUtf16ToScalar_cjk() {
|
|
437
|
-
let text = "\u{4F60}\u{597D}" // δ½ ε₯½
|
|
438
|
-
// UTF-16: 2 code units, 2 scalars
|
|
439
|
-
XCTAssertEqual(
|
|
440
|
-
PositionBridge.utf16OffsetToScalar(1, in: text), 1,
|
|
441
|
-
"CJK character at offset 1 should be scalar 1"
|
|
442
|
-
)
|
|
443
|
-
XCTAssertEqual(
|
|
444
|
-
PositionBridge.utf16OffsetToScalar(2, in: text), 2,
|
|
445
|
-
"End of CJK string should be scalar 2"
|
|
446
|
-
)
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/// CJK extension B characters (U+20000+) are in the supplementary plane.
|
|
450
|
-
func testUtf16ToScalar_cjkExtensionB() {
|
|
451
|
-
// U+20000 (CJK Unified Ideographs Extension B) = 2 UTF-16 code units, 1 scalar
|
|
452
|
-
let text = "A\u{20000}B"
|
|
453
|
-
// UTF-16: A(1) + U+20000(2) + B(1) = 4
|
|
454
|
-
// Scalars: A(1) + U+20000(1) + B(1) = 3
|
|
455
|
-
XCTAssertEqual(
|
|
456
|
-
PositionBridge.utf16OffsetToScalar(3, in: text), 2,
|
|
457
|
-
"After CJK Extension B character"
|
|
458
|
-
)
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// MARK: - UTF-16 -> Scalar: Mixed Content
|
|
462
|
-
|
|
463
|
-
/// Mixed ASCII, emoji, and CJK in one string.
|
|
464
|
-
func testUtf16ToScalar_mixedContent() {
|
|
465
|
-
let text = "Hi\u{1F600}\u{4F60}\u{597D}!"
|
|
466
|
-
// UTF-16: H(1) + i(1) + U+1F600(2) + U+4F60(1) + U+597D(1) + !(1) = 7
|
|
467
|
-
// Scalars: H(1) + i(1) + U+1F600(1) + U+4F60(1) + U+597D(1) + !(1) = 6
|
|
468
|
-
|
|
469
|
-
XCTAssertEqual(
|
|
470
|
-
PositionBridge.utf16OffsetToScalar(2, in: text), 2,
|
|
471
|
-
"After 'Hi'"
|
|
472
|
-
)
|
|
473
|
-
XCTAssertEqual(
|
|
474
|
-
PositionBridge.utf16OffsetToScalar(4, in: text), 3,
|
|
475
|
-
"After emoji (UTF-16 offset 4 = scalar 3)"
|
|
476
|
-
)
|
|
477
|
-
XCTAssertEqual(
|
|
478
|
-
PositionBridge.utf16OffsetToScalar(7, in: text), 6,
|
|
479
|
-
"End of mixed string"
|
|
480
|
-
)
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// MARK: - Scalar -> UTF-16: ASCII
|
|
484
|
-
|
|
485
|
-
func testScalarToUtf16_asciiOnly() {
|
|
486
|
-
let text = "Hello"
|
|
487
|
-
XCTAssertEqual(
|
|
488
|
-
PositionBridge.scalarToUtf16Offset(0, in: text), 0,
|
|
489
|
-
"Scalar 0 should map to UTF-16 offset 0"
|
|
490
|
-
)
|
|
491
|
-
XCTAssertEqual(
|
|
492
|
-
PositionBridge.scalarToUtf16Offset(3, in: text), 3,
|
|
493
|
-
"Scalar 3 in ASCII should map to UTF-16 offset 3"
|
|
494
|
-
)
|
|
495
|
-
XCTAssertEqual(
|
|
496
|
-
PositionBridge.scalarToUtf16Offset(5, in: text), 5,
|
|
497
|
-
"Scalar 5 (end) should map to UTF-16 offset 5"
|
|
498
|
-
)
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// MARK: - Scalar -> UTF-16: Surrogate Pairs
|
|
502
|
-
|
|
503
|
-
func testScalarToUtf16_surrogatePair() {
|
|
504
|
-
let text = "A\u{1F600}B"
|
|
505
|
-
// Scalar 0 = A -> UTF-16 offset 0
|
|
506
|
-
// Scalar 1 = U+1F600 -> UTF-16 offset 1
|
|
507
|
-
// Scalar 2 = B -> UTF-16 offset 3 (after 2 UTF-16 code units for emoji)
|
|
508
|
-
XCTAssertEqual(
|
|
509
|
-
PositionBridge.scalarToUtf16Offset(1, in: text), 1,
|
|
510
|
-
"Scalar 1 (emoji start) should be UTF-16 offset 1"
|
|
511
|
-
)
|
|
512
|
-
XCTAssertEqual(
|
|
513
|
-
PositionBridge.scalarToUtf16Offset(2, in: text), 3,
|
|
514
|
-
"Scalar 2 (after emoji) should be UTF-16 offset 3"
|
|
515
|
-
)
|
|
516
|
-
XCTAssertEqual(
|
|
517
|
-
PositionBridge.scalarToUtf16Offset(3, in: text), 4,
|
|
518
|
-
"Scalar 3 (after B) should be UTF-16 offset 4"
|
|
519
|
-
)
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// MARK: - Scalar -> UTF-16: Family Emoji
|
|
523
|
-
|
|
524
|
-
func testScalarToUtf16_familyEmoji() {
|
|
525
|
-
let text = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
|
|
526
|
-
// 7 scalars, 11 UTF-16 code units
|
|
527
|
-
// Scalar 7 (end) should map to UTF-16 offset 11
|
|
528
|
-
XCTAssertEqual(
|
|
529
|
-
PositionBridge.scalarToUtf16Offset(7, in: text), 11,
|
|
530
|
-
"End of family emoji: scalar 7 -> UTF-16 offset 11"
|
|
531
|
-
)
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// MARK: - Roundtrip Consistency
|
|
535
|
-
|
|
536
|
-
/// Verify that utf16->scalar->utf16 roundtrips correctly for various inputs.
|
|
537
|
-
func testRoundtrip_utf16ToScalarAndBack() {
|
|
538
|
-
let testCases: [(String, String)] = [
|
|
539
|
-
("Hello, world!", "ASCII"),
|
|
540
|
-
("A\u{1F600}B", "surrogate pair"),
|
|
541
|
-
("\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}", "family emoji"),
|
|
542
|
-
("\u{4F60}\u{597D}\u{4E16}\u{754C}", "CJK"),
|
|
543
|
-
("abc\u{1F600}\u{4F60}xyz", "mixed"),
|
|
544
|
-
("\u{0041}\u{0301}", "combining character (A + combining acute)"),
|
|
545
|
-
]
|
|
546
|
-
|
|
547
|
-
for (text, label) in testCases {
|
|
548
|
-
let nsString = text as NSString
|
|
549
|
-
// Test at every valid UTF-16 offset.
|
|
550
|
-
for utf16Offset in 0...nsString.length {
|
|
551
|
-
let scalar = PositionBridge.utf16OffsetToScalar(utf16Offset, in: text)
|
|
552
|
-
let backToUtf16 = PositionBridge.scalarToUtf16Offset(scalar, in: text)
|
|
553
|
-
// The roundtrip may not be exact if utf16Offset lands in the middle
|
|
554
|
-
// of a surrogate pair. But at surrogate boundaries it should match.
|
|
555
|
-
// We verify that the back-converted offset is >= the original
|
|
556
|
-
// (snapping forward past any mid-surrogate position).
|
|
557
|
-
XCTAssertGreaterThanOrEqual(
|
|
558
|
-
backToUtf16, utf16Offset - 1,
|
|
559
|
-
"Roundtrip for '\(label)' at UTF-16 offset \(utf16Offset): " +
|
|
560
|
-
"scalar=\(scalar), back=\(backToUtf16)"
|
|
561
|
-
)
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/// Verify that scalar->utf16->scalar roundtrips exactly.
|
|
567
|
-
func testRoundtrip_scalarToUtf16AndBack() {
|
|
568
|
-
let testCases: [(String, String)] = [
|
|
569
|
-
("Hello, world!", "ASCII"),
|
|
570
|
-
("A\u{1F600}B", "surrogate pair"),
|
|
571
|
-
("\u{4F60}\u{597D}", "CJK"),
|
|
572
|
-
("abc\u{1F600}\u{4F60}xyz", "mixed"),
|
|
573
|
-
]
|
|
574
|
-
|
|
575
|
-
for (text, label) in testCases {
|
|
576
|
-
let scalarCount = UInt32(text.unicodeScalars.count)
|
|
577
|
-
for scalarOffset in 0...scalarCount {
|
|
578
|
-
let utf16 = PositionBridge.scalarToUtf16Offset(scalarOffset, in: text)
|
|
579
|
-
let backToScalar = PositionBridge.utf16OffsetToScalar(utf16, in: text)
|
|
580
|
-
XCTAssertEqual(
|
|
581
|
-
backToScalar, scalarOffset,
|
|
582
|
-
"Roundtrip for '\(label)' at scalar \(scalarOffset): " +
|
|
583
|
-
"utf16=\(utf16), back=\(backToScalar) - should equal original scalar"
|
|
584
|
-
)
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// MARK: - Grapheme Boundary Snapping
|
|
590
|
-
|
|
591
|
-
/// Snapping at an already-valid boundary returns the same offset.
|
|
592
|
-
func testSnapToGraphemeBoundary_alreadyOnBoundary() {
|
|
593
|
-
let text = "Hello"
|
|
594
|
-
for i in 0...5 {
|
|
595
|
-
XCTAssertEqual(
|
|
596
|
-
PositionBridge.snapToGraphemeBoundary(i, in: text), i,
|
|
597
|
-
"ASCII offset \(i) is already on a grapheme boundary"
|
|
598
|
-
)
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/// Snapping in the middle of a surrogate pair snaps to the end of the grapheme cluster.
|
|
603
|
-
func testSnapToGraphemeBoundary_midSurrogatePair() {
|
|
604
|
-
let text = "A\u{1F600}B"
|
|
605
|
-
// UTF-16: A(offset 0), emoji high surrogate(offset 1), low surrogate(offset 2), B(offset 3)
|
|
606
|
-
// Offset 2 is mid-surrogate. The grapheme cluster for the emoji is offsets 1-2.
|
|
607
|
-
// Snapping offset 2 should go to 3 (end of emoji grapheme).
|
|
608
|
-
let snapped = PositionBridge.snapToGraphemeBoundary(2, in: text)
|
|
609
|
-
XCTAssertEqual(
|
|
610
|
-
snapped, 3,
|
|
611
|
-
"Mid-surrogate offset 2 should snap to 3 (end of emoji grapheme)"
|
|
612
|
-
)
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/// Snapping in the middle of a family emoji snaps to the end.
|
|
616
|
-
func testSnapToGraphemeBoundary_midFamilyEmoji() {
|
|
617
|
-
let text = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
|
|
618
|
-
// The entire sequence is 1 grapheme cluster = 11 UTF-16 code units.
|
|
619
|
-
// Any offset 1..10 should snap to 11.
|
|
620
|
-
for midOffset in 1...10 {
|
|
621
|
-
let snapped = PositionBridge.snapToGraphemeBoundary(midOffset, in: text)
|
|
622
|
-
XCTAssertEqual(
|
|
623
|
-
snapped, 11,
|
|
624
|
-
"Mid-family-emoji offset \(midOffset) should snap to 11 (end of grapheme)"
|
|
625
|
-
)
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/// Snapping in the middle of a combining character sequence.
|
|
630
|
-
func testSnapToGraphemeBoundary_combiningCharacter() {
|
|
631
|
-
// "e" + combining acute accent = "e\u{0301}" = "e" visually with accent
|
|
632
|
-
// This is 2 UTF-16 code units but 1 grapheme cluster.
|
|
633
|
-
let text = "e\u{0301}"
|
|
634
|
-
let snapped = PositionBridge.snapToGraphemeBoundary(1, in: text)
|
|
635
|
-
XCTAssertEqual(
|
|
636
|
-
snapped, 2,
|
|
637
|
-
"Between base and combining character should snap to end of grapheme"
|
|
638
|
-
)
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/// Snapping on empty string always returns 0.
|
|
642
|
-
func testSnapToGraphemeBoundary_emptyString() {
|
|
643
|
-
XCTAssertEqual(
|
|
644
|
-
PositionBridge.snapToGraphemeBoundary(0, in: ""), 0,
|
|
645
|
-
"Empty string should always return 0"
|
|
646
|
-
)
|
|
647
|
-
XCTAssertEqual(
|
|
648
|
-
PositionBridge.snapToGraphemeBoundary(5, in: ""), 0,
|
|
649
|
-
"Empty string with out-of-range offset should return 0"
|
|
650
|
-
)
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/// Snapping at the end of string returns the end offset.
|
|
654
|
-
func testSnapToGraphemeBoundary_atEnd() {
|
|
655
|
-
let text = "Hello"
|
|
656
|
-
XCTAssertEqual(
|
|
657
|
-
PositionBridge.snapToGraphemeBoundary(5, in: text), 5,
|
|
658
|
-
"At end of string should return end offset"
|
|
659
|
-
)
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
/// Snapping with negative offset clamps to 0.
|
|
663
|
-
func testSnapToGraphemeBoundary_negativeOffset() {
|
|
664
|
-
let text = "Hello"
|
|
665
|
-
XCTAssertEqual(
|
|
666
|
-
PositionBridge.snapToGraphemeBoundary(-1, in: text), 0,
|
|
667
|
-
"Negative offset should clamp to 0"
|
|
668
|
-
)
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// MARK: - Flag Emoji (Regional Indicators)
|
|
672
|
-
|
|
673
|
-
/// Flag emoji are composed of two regional indicator symbols.
|
|
674
|
-
/// e.g. πΊπΈ = U+1F1FA U+1F1F8 = 4 UTF-16 code units, 2 scalars, 1 grapheme.
|
|
675
|
-
func testUtf16ToScalar_flagEmoji() {
|
|
676
|
-
let text = "A\u{1F1FA}\u{1F1F8}B"
|
|
677
|
-
// UTF-16: A(1) + U+1F1FA(2) + U+1F1F8(2) + B(1) = 6
|
|
678
|
-
// Scalars: A(1) + U+1F1FA(1) + U+1F1F8(1) + B(1) = 4
|
|
679
|
-
|
|
680
|
-
XCTAssertEqual(
|
|
681
|
-
PositionBridge.utf16OffsetToScalar(1, in: text), 1,
|
|
682
|
-
"After 'A'"
|
|
683
|
-
)
|
|
684
|
-
XCTAssertEqual(
|
|
685
|
-
PositionBridge.utf16OffsetToScalar(5, in: text), 3,
|
|
686
|
-
"After flag emoji (UTF-16 offset 5 = scalar 3)"
|
|
687
|
-
)
|
|
688
|
-
XCTAssertEqual(
|
|
689
|
-
PositionBridge.utf16OffsetToScalar(6, in: text), 4,
|
|
690
|
-
"After 'B'"
|
|
691
|
-
)
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
func testSnapToGraphemeBoundary_flagEmoji() {
|
|
695
|
-
let text = "\u{1F1FA}\u{1F1F8}"
|
|
696
|
-
// 4 UTF-16 code units, 1 grapheme cluster.
|
|
697
|
-
// Offsets 1, 2, 3 should snap to 4.
|
|
698
|
-
for midOffset in 1...3 {
|
|
699
|
-
let snapped = PositionBridge.snapToGraphemeBoundary(midOffset, in: text)
|
|
700
|
-
XCTAssertEqual(
|
|
701
|
-
snapped, 4,
|
|
702
|
-
"Mid-flag-emoji offset \(midOffset) should snap to 4"
|
|
703
|
-
)
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|