@openeditor/react-native-prose-editor 0.0.9 โ†’ 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/README.md +5 -9
  2. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +23 -0
  3. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +2 -2
  4. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +476 -53
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +110 -169
  6. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +1 -1
  7. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +124 -337
  8. package/dist/EditorTheme.d.ts +8 -0
  9. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  10. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  11. package/ios/Generated_editor_core.swift +15 -0
  12. package/ios/NativeBlockEditorSurface.swift +371 -60
  13. package/ios/NativeEditorExpoView.swift +114 -117
  14. package/ios/NativeInputSupport.swift +0 -302
  15. package/ios/PositionBridge.swift +22 -548
  16. package/ios/RichTextEditorView.swift +217 -4671
  17. package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
  18. package/package.json +1 -1
  19. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  20. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  21. package/rust/android/x86_64/libeditor_core.so +0 -0
  22. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +31 -0
  23. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +0 -50
  24. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +0 -4068
  25. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +0 -1009
  26. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +0 -6
@@ -1,558 +1,32 @@
1
1
  import UIKit
2
- import ObjectiveC
3
2
 
4
- // MARK: - PositionBridge
5
-
6
- /// Converts between UITextView cursor positions (UTF-16 code unit offsets, snapped
7
- /// to grapheme cluster boundaries) and Rust editor-core scalar offsets (Unicode
8
- /// scalar values = Unicode code points).
9
- ///
10
- /// UIKit's text system uses UTF-16 internally (NSString). Emoji like U+1F468
11
- /// (man) occupy 2 UTF-16 code units (a surrogate pair) but 1 Unicode scalar.
12
- /// Composed emoji sequences like ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ are multiple scalars joined by
13
- /// ZWJ but render as a single grapheme cluster.
14
- ///
15
- /// Rust's editor-core counts positions in Unicode scalars (what Rust calls `char`).
16
- /// The PositionMap in Rust converts between doc positions and scalar offsets.
17
- /// This bridge converts between those scalar offsets and UITextView UTF-16 offsets.
3
+ /// Converts native UTF-16 offsets inside one structural text leaf to the
4
+ /// Unicode-scalar offsets used by the Rust editor engine.
18
5
  final class PositionBridge {
19
-
20
- private struct StringConversionTable {
21
- let utf16ToScalar: [UInt32]
22
- let scalarToUtf16: [Int]
23
- }
24
-
25
- private final class TextViewConversionTable: NSObject {
26
- let adjustedUtf16ToScalar: [UInt32]
27
-
28
- init(adjustedUtf16ToScalar: [UInt32]) {
29
- self.adjustedUtf16ToScalar = adjustedUtf16ToScalar
30
- }
31
- }
32
-
33
- struct VirtualListMarker {
34
- let paragraphStartUtf16: Int
35
- let scalarLength: UInt32
36
- }
37
-
38
- private struct PositionAdjustments {
39
- let placeholders: [Int]
40
- let listMarkers: [VirtualListMarker]
41
- }
42
-
43
- private static var textViewConversionTableKey: UInt8 = 0
44
- private static let stringTableLock = NSLock()
45
- private static var lastStringTableText = ""
46
- private static var lastStringTable: StringConversionTable?
47
-
48
- // MARK: - UTF-16 <-> Scalar Conversion
49
-
50
- /// Convert a UITextView cursor position (UTF-16 offset) to a Rust scalar offset.
51
- ///
52
- /// Walks the string from the beginning, counting Unicode scalars consumed as
53
- /// we advance through UTF-16 code units. Surrogate pairs (code units > U+FFFF)
54
- /// contribute 2 UTF-16 code units but only 1 scalar.
55
- ///
56
- /// - Parameters:
57
- /// - position: A `UITextPosition` obtained from the text view.
58
- /// - textView: The text view containing the text.
59
- /// - Returns: The equivalent Unicode scalar offset.
60
6
  static func textViewToScalar(_ position: UITextPosition, in textView: UITextView) -> UInt32 {
61
- let utf16Offset = textView.offset(from: textView.beginningOfDocument, to: position)
62
- return utf16OffsetToScalar(utf16Offset, in: textView)
63
- }
64
-
65
- /// Convert a Rust scalar offset to a UITextView position.
66
- ///
67
- /// Walks the string counting scalars until we reach the target, then returns
68
- /// the corresponding UTF-16 offset as a UITextPosition.
69
- ///
70
- /// - Parameters:
71
- /// - scalar: The Unicode scalar offset from Rust.
72
- /// - textView: The text view containing the text.
73
- /// - Returns: The equivalent `UITextPosition`, or the end of document if the
74
- /// scalar offset exceeds the text length.
75
- static func scalarToTextView(_ scalar: UInt32, in textView: UITextView) -> UITextPosition {
76
- let utf16Offset = scalarToUtf16Offset(scalar, in: textView)
77
- return textView.position(
78
- from: textView.beginningOfDocument,
79
- offset: utf16Offset
80
- ) ?? textView.endOfDocument
81
- }
82
-
83
- static func utf16OffsetToScalar(_ utf16Offset: Int, in textView: UITextView) -> UInt32 {
84
- let text = textView.text ?? ""
85
- let conversionTable = textViewConversionTable(for: textView)
86
- guard !conversionTable.adjustedUtf16ToScalar.isEmpty else { return 0 }
87
- let clampedOffset = min(
88
- max(utf16Offset, 0),
89
- min((text as NSString).length, conversionTable.adjustedUtf16ToScalar.count - 1)
7
+ utf16OffsetToScalar(
8
+ textView.offset(from: textView.beginningOfDocument, to: position),
9
+ in: textView.text ?? ""
90
10
  )
91
- return conversionTable.adjustedUtf16ToScalar[clampedOffset]
92
- }
93
-
94
- static func utf16OffsetToScalar(_ utf16Offset: Int, in attributedString: NSAttributedString) -> UInt32 {
95
- let conversionTable = adjustedConversionTable(for: attributedString)
96
- guard !conversionTable.isEmpty else { return 0 }
97
- let clampedOffset = min(max(utf16Offset, 0), conversionTable.count - 1)
98
- return conversionTable[clampedOffset]
99
- }
100
-
101
- static func scalarToUtf16Offset(_ scalar: UInt32, in textView: UITextView) -> Int {
102
- let conversionTable = textViewConversionTable(for: textView)
103
- let utf16ToScalar = conversionTable.adjustedUtf16ToScalar
104
- return scalarToUtf16Offset(scalar, inAdjustedUtf16ToScalarTable: utf16ToScalar)
105
- }
106
-
107
- static func scalarToUtf16Offset(_ scalar: UInt32, in attributedString: NSAttributedString) -> Int {
108
- let utf16ToScalar = adjustedConversionTable(for: attributedString)
109
- return scalarToUtf16Offset(scalar, inAdjustedUtf16ToScalarTable: utf16ToScalar)
110
- }
111
-
112
- private static func scalarToUtf16Offset(
113
- _ scalar: UInt32,
114
- inAdjustedUtf16ToScalarTable utf16ToScalar: [UInt32]
115
- ) -> Int {
116
- guard scalar > 0, !utf16ToScalar.isEmpty else {
117
- return 0
118
- }
119
-
120
- if let last = utf16ToScalar.last, scalar > last {
121
- return utf16ToScalar.count - 1
122
- }
123
-
124
- var low = 0
125
- var high = utf16ToScalar.count - 1
126
- while low < high {
127
- let mid = (low + high) / 2
128
- if utf16ToScalar[mid] < scalar {
129
- low = mid + 1
130
- } else {
131
- high = mid
132
- }
133
- }
134
-
135
- return low
136
11
  }
137
12
 
138
- /// Convert a UTF-16 offset to a Unicode scalar offset within a string.
139
- ///
140
- /// This is the core conversion used by `textViewToScalar`. Exposed as a
141
- /// static method for direct use and testing.
142
- ///
143
- /// - Parameters:
144
- /// - utf16Offset: The UTF-16 code unit offset.
145
- /// - text: The string to walk.
146
- /// - Returns: The number of Unicode scalars from the start to the given UTF-16 offset.
147
13
  static func utf16OffsetToScalar(_ utf16Offset: Int, in text: String) -> UInt32 {
148
- let conversionTable = stringConversionTable(for: text)
149
- let clampedOffset = min(max(utf16Offset, 0), conversionTable.utf16ToScalar.count - 1)
150
- return conversionTable.utf16ToScalar[clampedOffset]
151
- }
152
-
153
- /// Convert a Unicode scalar offset to a UTF-16 offset within a string.
154
- ///
155
- /// This is the core conversion used by `scalarToTextView`. Exposed as a
156
- /// static method for direct use and testing.
157
- ///
158
- /// - Parameters:
159
- /// - scalar: The Unicode scalar offset.
160
- /// - text: The string to walk.
161
- /// - Returns: The number of UTF-16 code units from the start to the given scalar offset.
162
- static func scalarToUtf16Offset(_ scalar: UInt32, in text: String) -> Int {
163
- let conversionTable = stringConversionTable(for: text)
164
- guard scalar > 0 else { return 0 }
165
- let scalarIndex = min(Int(scalar), conversionTable.scalarToUtf16.count - 1)
166
- return conversionTable.scalarToUtf16[scalarIndex]
167
- }
168
-
169
- // MARK: - Grapheme Boundary Snapping
170
-
171
- /// Snap a UTF-16 offset to the nearest grapheme cluster boundary.
172
- ///
173
- /// UITextView may report offsets in the middle of a grapheme cluster (e.g.
174
- /// between the scalars of a flag emoji or a composed character sequence).
175
- /// This method snaps the offset forward to the end of the current grapheme
176
- /// cluster, since that is the position the user would perceive.
177
- ///
178
- /// - Parameters:
179
- /// - utf16Offset: A UTF-16 code unit offset that may be mid-grapheme.
180
- /// - text: The string to inspect.
181
- /// - Returns: The nearest grapheme-aligned UTF-16 offset. If the input is
182
- /// already on a boundary, it is returned unchanged.
183
- static func snapToGraphemeBoundary(_ utf16Offset: Int, in text: String) -> Int {
184
- guard !text.isEmpty else { return 0 }
185
-
186
- let nsString = text as NSString
187
- let clampedOffset = min(max(utf16Offset, 0), nsString.length)
188
-
189
- // If we're at the very start or end, already on a boundary.
190
- if clampedOffset == 0 || clampedOffset == nsString.length {
191
- return clampedOffset
192
- }
193
-
194
- // composedCharacterSequence(at:) returns the full grapheme cluster range
195
- // containing the given UTF-16 index. We snap to the end of that range
196
- // (forward bias) since that's what a user moving the cursor expects.
197
- let range = nsString.rangeOfComposedCharacterSequence(at: clampedOffset)
198
-
199
- // If the offset is already at the start of a grapheme cluster, it's on a boundary.
200
- if range.location == clampedOffset {
201
- return clampedOffset
202
- }
203
-
204
- // Otherwise, snap to the end of this cluster.
205
- return NSMaxRange(range)
206
- }
207
-
208
- // MARK: - UITextRange <-> Scalar Range
209
-
210
- /// Convert a UITextRange to a (from, to) pair of Rust scalar offsets.
211
- ///
212
- /// - Parameters:
213
- /// - range: A `UITextRange` from the text view.
214
- /// - textView: The text view containing the text.
215
- /// - Returns: A tuple of (from, to) scalar offsets where from <= to.
216
- static func textRangeToScalarRange(
217
- _ range: UITextRange,
218
- in textView: UITextView
219
- ) -> (from: UInt32, to: UInt32) {
220
- let from = textViewToScalar(range.start, in: textView)
221
- let to = textViewToScalar(range.end, in: textView)
222
- return (from: min(from, to), to: max(from, to))
223
- }
224
-
225
- /// Convert a pair of Rust scalar offsets to a UITextRange.
226
- ///
227
- /// - Parameters:
228
- /// - from: The start scalar offset.
229
- /// - to: The end scalar offset.
230
- /// - textView: The text view.
231
- /// - Returns: The corresponding `UITextRange`, or nil if the positions are invalid.
232
- static func scalarRangeToTextRange(
233
- from: UInt32,
234
- to: UInt32,
235
- in textView: UITextView
236
- ) -> UITextRange? {
237
- let startPos = scalarToTextView(from, in: textView)
238
- let endPos = scalarToTextView(to, in: textView)
239
- return textView.textRange(from: startPos, to: endPos)
240
- }
241
-
242
- // MARK: - Cursor Scalar Offset (Convenience)
243
-
244
- /// Get the current cursor position as a Rust scalar offset.
245
- ///
246
- /// If there is a range selection, returns the head (moving end) position.
247
- ///
248
- /// - Parameter textView: The text view.
249
- /// - Returns: The scalar offset of the cursor, or 0 if no selection exists.
250
- static func cursorScalarOffset(in textView: UITextView) -> UInt32 {
251
- guard let selectedRange = textView.selectedTextRange else { return 0 }
252
- return textViewToScalar(selectedRange.end, in: textView)
253
- }
254
-
255
- static func virtualListMarker(
256
- atUtf16Offset utf16Offset: Int,
257
- in textView: UITextView
258
- ) -> VirtualListMarker? {
259
- virtualListMarkers(in: textView.textStorage).first { $0.paragraphStartUtf16 == utf16Offset }
260
- }
261
-
262
- static func invalidateCache(for textView: UITextView) {
263
- objc_setAssociatedObject(
264
- textView,
265
- &textViewConversionTableKey,
266
- nil,
267
- .OBJC_ASSOCIATION_RETAIN_NONATOMIC
268
- )
269
- }
270
-
271
- @discardableResult
272
- static func applyAttributedPatchIfPossible(
273
- for textView: UITextView,
274
- replaceRange: NSRange,
275
- replacement: NSAttributedString
276
- ) -> Bool {
277
- guard let cached = objc_getAssociatedObject(textView, &textViewConversionTableKey) as? TextViewConversionTable else {
278
- return false
279
- }
280
-
281
- let oldAdjusted = cached.adjustedUtf16ToScalar
282
- let oldUtf16Count = max(0, oldAdjusted.count - 1)
283
- guard replaceRange.location >= 0,
284
- replaceRange.length >= 0,
285
- replaceRange.location + replaceRange.length <= oldUtf16Count
286
- else {
287
- return false
288
- }
289
-
290
- let startOffset = replaceRange.location
291
- let endOffset = replaceRange.location + replaceRange.length
292
- let replacementAdjusted = adjustedConversionTable(for: replacement)
293
- let patched = patchedAdjustedConversionTable(
294
- oldAdjusted: oldAdjusted,
295
- startOffset: startOffset,
296
- endOffset: endOffset,
297
- replacementAdjusted: replacementAdjusted
298
- )
299
-
300
- objc_setAssociatedObject(
301
- textView,
302
- &textViewConversionTableKey,
303
- TextViewConversionTable(adjustedUtf16ToScalar: patched),
304
- .OBJC_ASSOCIATION_RETAIN_NONATOMIC
305
- )
306
- return true
307
- }
308
-
309
- @discardableResult
310
- static func applyPlainTextPatchIfPossible(
311
- for textView: UITextView,
312
- replaceRange: NSRange,
313
- replacementText: String
314
- ) -> Bool {
315
- guard let cached = objc_getAssociatedObject(textView, &textViewConversionTableKey) as? TextViewConversionTable else {
316
- return false
317
- }
318
-
319
- let oldAdjusted = cached.adjustedUtf16ToScalar
320
- let oldUtf16Count = max(0, oldAdjusted.count - 1)
321
- guard replaceRange.location >= 0,
322
- replaceRange.length >= 0,
323
- replaceRange.location + replaceRange.length <= oldUtf16Count
324
- else {
325
- return false
326
- }
327
-
328
- let startOffset = replaceRange.location
329
- let endOffset = replaceRange.location + replaceRange.length
330
- let replacementBase = stringConversionTable(for: replacementText).utf16ToScalar
331
- let patched = patchedAdjustedConversionTable(
332
- oldAdjusted: oldAdjusted,
333
- startOffset: startOffset,
334
- endOffset: endOffset,
335
- replacementAdjusted: replacementBase
336
- )
337
-
338
- objc_setAssociatedObject(
339
- textView,
340
- &textViewConversionTableKey,
341
- TextViewConversionTable(adjustedUtf16ToScalar: patched),
342
- .OBJC_ASSOCIATION_RETAIN_NONATOMIC
343
- )
344
- return true
345
- }
346
-
347
- private static func patchedAdjustedConversionTable(
348
- oldAdjusted: [UInt32],
349
- startOffset: Int,
350
- endOffset: Int,
351
- replacementAdjusted: [UInt32]
352
- ) -> [UInt32] {
353
- let startScalar = Int32(oldAdjusted[startOffset])
354
- let deletedScalarCount = Int32(oldAdjusted[endOffset]) - startScalar
355
- let replacementScalarCount = Int32(replacementAdjusted.last ?? 0)
356
- let scalarDelta = replacementScalarCount - deletedScalarCount
357
- let replacement = replacementAdjusted.map { value in
358
- UInt32(max(0, Int32(value) + startScalar))
359
- }
360
- let prefix = Array(oldAdjusted[..<startOffset])
361
- let suffix = oldAdjusted[(endOffset + 1)...].map { value in
362
- UInt32(max(0, Int32(value) + scalarDelta))
363
- }
364
- return prefix + replacement + suffix
365
- }
366
-
367
- private static func stringConversionTable(for text: String) -> StringConversionTable {
368
- stringTableLock.lock()
369
- if lastStringTableText == text, let lastStringTable {
370
- stringTableLock.unlock()
371
- return lastStringTable
372
- }
373
- stringTableLock.unlock()
374
-
375
- let utf16Count = text.utf16.count
376
- let scalarCount = text.unicodeScalars.count
377
- var utf16ToScalar = Array(repeating: UInt32(0), count: utf16Count + 1)
378
- var scalarToUtf16 = Array(repeating: 0, count: scalarCount + 1)
379
- var utf16Pos = 0
380
- var scalarPos = 0
381
-
382
- for scalar in text.unicodeScalars {
383
- let nextUtf16Pos = utf16Pos + scalar.utf16.count
384
- scalarPos += 1
385
- if nextUtf16Pos > utf16Pos {
386
- for offset in (utf16Pos + 1)...nextUtf16Pos {
387
- utf16ToScalar[offset] = UInt32(scalarPos)
388
- }
389
- }
390
- scalarToUtf16[scalarPos] = nextUtf16Pos
391
- utf16Pos = nextUtf16Pos
392
- }
393
-
394
- let conversionTable = StringConversionTable(
395
- utf16ToScalar: utf16ToScalar,
396
- scalarToUtf16: scalarToUtf16
397
- )
398
-
399
- stringTableLock.lock()
400
- lastStringTableText = text
401
- lastStringTable = conversionTable
402
- stringTableLock.unlock()
403
-
404
- return conversionTable
405
- }
406
-
407
- private static func adjustedConversionTable(for attributedString: NSAttributedString) -> [UInt32] {
408
- let baseTable = stringConversionTable(for: attributedString.string)
409
- let adjustments = positionAdjustments(in: attributedString)
410
- return adjustedUtf16ToScalar(
411
- baseUtf16ToScalar: baseTable.utf16ToScalar,
412
- placeholders: adjustments.placeholders,
413
- listMarkers: adjustments.listMarkers
414
- )
415
- }
416
-
417
- private static func textViewConversionTable(for textView: UITextView) -> TextViewConversionTable {
418
- if let cached = objc_getAssociatedObject(textView, &textViewConversionTableKey) as? TextViewConversionTable {
419
- return cached
420
- }
421
-
422
- let text = textView.text ?? ""
423
- let baseTable = stringConversionTable(for: text)
424
- let adjustments = positionAdjustments(in: textView.textStorage)
425
- let adjustedUtf16ToScalar = adjustedUtf16ToScalar(
426
- baseUtf16ToScalar: baseTable.utf16ToScalar,
427
- placeholders: adjustments.placeholders,
428
- listMarkers: adjustments.listMarkers
429
- )
430
- let conversionTable = TextViewConversionTable(adjustedUtf16ToScalar: adjustedUtf16ToScalar)
431
- objc_setAssociatedObject(
432
- textView,
433
- &textViewConversionTableKey,
434
- conversionTable,
435
- .OBJC_ASSOCIATION_RETAIN_NONATOMIC
436
- )
437
- return conversionTable
438
- }
439
-
440
- private static func adjustedUtf16ToScalar(
441
- baseUtf16ToScalar: [UInt32],
442
- placeholders: [Int],
443
- listMarkers: [VirtualListMarker] = [],
444
- ) -> [UInt32] {
445
- let utf16Count = max(0, baseUtf16ToScalar.count - 1)
446
- var deltas = Array(repeating: Int32(0), count: utf16Count + 2)
447
-
448
- for placeholderOffset in placeholders {
449
- let startOffset = min(max(placeholderOffset + 1, 0), utf16Count + 1)
450
- if startOffset <= utf16Count {
451
- deltas[startOffset] -= 1
452
- }
453
- }
454
-
455
- for marker in listMarkers {
456
- let startOffset = min(max(marker.paragraphStartUtf16, 0), utf16Count)
457
- deltas[startOffset] += Int32(marker.scalarLength)
458
- }
459
-
460
- var adjustedUtf16ToScalar = Array(repeating: UInt32(0), count: utf16Count + 1)
461
- var runningDelta: Int32 = 0
462
- for offset in 0...utf16Count {
463
- runningDelta += deltas[offset]
464
- let adjustedValue = Int32(baseUtf16ToScalar[offset]) + runningDelta
465
- adjustedUtf16ToScalar[offset] = UInt32(max(0, adjustedValue))
466
- }
467
- return adjustedUtf16ToScalar
468
- }
469
-
470
- private static func adjustedUtf16ToScalar(
471
- baseUtf16ToScalar: [UInt32],
472
- listMarkers: [VirtualListMarker]
473
- ) -> [UInt32] {
474
- adjustedUtf16ToScalar(baseUtf16ToScalar: baseUtf16ToScalar, placeholders: [], listMarkers: listMarkers)
475
- }
476
-
477
- private static func virtualListMarkers(in attributedString: NSAttributedString) -> [VirtualListMarker] {
478
- positionAdjustments(in: attributedString).listMarkers
479
- }
480
-
481
- private static func positionAdjustments(in attributedString: NSAttributedString) -> PositionAdjustments {
482
- guard attributedString.length > 0 else {
483
- return PositionAdjustments(placeholders: [], listMarkers: [])
484
- }
485
-
486
- let nsString = attributedString.string as NSString
487
- var placeholders: [Int] = []
488
- var markers: [VirtualListMarker] = []
489
- var seenStarts = Set<Int>()
490
- let fullRange = NSRange(location: 0, length: attributedString.length)
491
-
492
- attributedString.enumerateAttributes(
493
- in: fullRange,
494
- options: [.longestEffectiveRangeNotRequired]
495
- ) { attrs, range, _ in
496
- guard range.length > 0 else { return }
497
-
498
- if attrs[InputCoordinatorAttributes.syntheticPlaceholder] as? Bool == true {
499
- placeholders.append(range.location)
500
- }
501
-
502
- guard let listContext = attrs[InputCoordinatorAttributes.listMarkerContext] as? [String: Any] else {
503
- return
504
- }
505
-
506
- let paragraphStart = nsString.paragraphRange(
507
- for: NSRange(location: range.location, length: 0)
508
- ).location
509
- guard !isParagraphStartCreatedByHardBreak(
510
- paragraphStart,
511
- in: attributedString
512
- ) else {
513
- return
514
- }
515
- guard seenStarts.insert(paragraphStart).inserted else { return }
516
-
517
- let markerLength = UInt32(
518
- NativeListMarker.text(for: listContext).unicodeScalars.count
519
- )
520
- markers.append(
521
- VirtualListMarker(
522
- paragraphStartUtf16: paragraphStart,
523
- scalarLength: markerLength
524
- )
525
- )
526
- }
527
-
528
- return PositionAdjustments(
529
- placeholders: placeholders,
530
- listMarkers: markers.sorted { $0.paragraphStartUtf16 < $1.paragraphStartUtf16 }
531
- )
532
- }
533
-
534
- private static func virtualListMarkers(in textStorage: NSTextStorage) -> [VirtualListMarker] {
535
- virtualListMarkers(in: textStorage as NSAttributedString)
536
- }
537
-
538
- private static func syntheticPlaceholderOffsets(in attributedString: NSAttributedString) -> [Int] {
539
- positionAdjustments(in: attributedString).placeholders
540
- }
541
-
542
- private static func syntheticPlaceholderOffsets(in textStorage: NSTextStorage) -> [Int] {
543
- syntheticPlaceholderOffsets(in: textStorage as NSAttributedString)
544
- }
545
-
546
- private static func isParagraphStartCreatedByHardBreak(
547
- _ paragraphStart: Int,
548
- in attributedString: NSAttributedString
549
- ) -> Bool {
550
- guard paragraphStart > 0, paragraphStart <= attributedString.length else { return false }
551
- let previousVoidType = attributedString.attribute(
552
- InputCoordinatorAttributes.voidNodeType,
553
- at: paragraphStart - 1,
554
- effectiveRange: nil
555
- ) as? String
556
- return previousVoidType == "hardBreak"
14
+ let utf16 = text.utf16
15
+ let clamped = min(max(utf16Offset, 0), utf16.count)
16
+ let index = utf16.index(utf16.startIndex, offsetBy: clamped)
17
+ guard let scalarIndex = String.Index(index, within: text) else {
18
+ let safe = max(0, clamped - 1)
19
+ let safeIndex = utf16.index(utf16.startIndex, offsetBy: safe)
20
+ return UInt32(text[..<(String.Index(safeIndex, within: text) ?? text.startIndex)].unicodeScalars.count)
21
+ }
22
+ return UInt32(text[..<scalarIndex].unicodeScalars.count)
23
+ }
24
+
25
+ static func scalarToUtf16Offset(_ scalarOffset: UInt32, in text: String) -> Int {
26
+ let scalars = text.unicodeScalars
27
+ let clamped = min(Int(scalarOffset), scalars.count)
28
+ let scalarIndex = scalars.index(scalars.startIndex, offsetBy: clamped)
29
+ let stringIndex = scalarIndex.samePosition(in: text) ?? text.endIndex
30
+ return text[..<stringIndex].utf16.count
557
31
  }
558
32
  }