@apollohg/react-native-prose-editor 0.1.0

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 (47) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +143 -0
  3. package/android/build.gradle +39 -0
  4. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  5. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
  11. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
  14. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/EditorAddons.swift +228 -0
  19. package/ios/EditorCore.xcframework/Info.plist +44 -0
  20. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  21. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  22. package/ios/EditorLayoutManager.swift +254 -0
  23. package/ios/EditorTheme.swift +372 -0
  24. package/ios/Generated_editor_core.swift +1143 -0
  25. package/ios/NativeEditorExpoView.swift +1417 -0
  26. package/ios/NativeEditorModule.swift +263 -0
  27. package/ios/PositionBridge.swift +278 -0
  28. package/ios/ReactNativeProseEditor.podspec +49 -0
  29. package/ios/RenderBridge.swift +825 -0
  30. package/ios/RichTextEditorView.swift +1559 -0
  31. package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
  32. package/ios/editor_coreFFI/module.modulemap +7 -0
  33. package/ios/editor_coreFFI.h +904 -0
  34. package/ios/editor_coreFFI.modulemap +7 -0
  35. package/package.json +66 -0
  36. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  37. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  38. package/rust/android/x86_64/libeditor_core.so +0 -0
  39. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
  40. package/src/EditorTheme.ts +130 -0
  41. package/src/EditorToolbar.tsx +620 -0
  42. package/src/NativeEditorBridge.ts +607 -0
  43. package/src/NativeRichTextEditor.tsx +951 -0
  44. package/src/addons.ts +158 -0
  45. package/src/index.ts +63 -0
  46. package/src/schemas.ts +153 -0
  47. package/src/useNativeEditor.ts +173 -0
@@ -0,0 +1,1559 @@
1
+ import UIKit
2
+ import os
3
+
4
+ // MARK: - EditorTextViewDelegate
5
+
6
+ /// Delegate protocol for EditorTextView to communicate state changes
7
+ /// back to the hosting view (Fabric component or UIKit container).
8
+ protocol EditorTextViewDelegate: AnyObject {
9
+ /// Called when the editor's selection changes.
10
+ /// - Parameters:
11
+ /// - textView: The editor text view.
12
+ /// - anchor: Scalar offset of the selection anchor.
13
+ /// - head: Scalar offset of the selection head.
14
+ func editorTextView(_ textView: EditorTextView, selectionDidChange anchor: UInt32, head: UInt32)
15
+
16
+ /// Called when the editor content is updated after a Rust operation.
17
+ /// - Parameters:
18
+ /// - textView: The editor text view.
19
+ /// - updateJSON: The full EditorUpdate JSON string from Rust.
20
+ func editorTextView(_ textView: EditorTextView, didReceiveUpdate updateJSON: String)
21
+ }
22
+
23
+ enum EditorHeightBehavior: String {
24
+ case fixed
25
+ case autoGrow
26
+ }
27
+
28
+ // MARK: - EditorTextView
29
+
30
+ /// UITextView subclass that intercepts all text input and routes it through
31
+ /// the Rust editor-core engine via UniFFI bindings.
32
+ ///
33
+ /// Instead of letting UITextView's internal text storage handle insertions
34
+ /// and deletions, this class captures the user's intent (typing, deleting,
35
+ /// pasting, autocorrect) and sends it to the Rust editor. The Rust editor
36
+ /// returns render elements, which are converted to NSAttributedString via
37
+ /// RenderBridge and applied back to the text view.
38
+ ///
39
+ /// This is the "input interception" pattern: the UITextView is effectively
40
+ /// a rendering surface, not a text editing engine.
41
+ ///
42
+ /// ## Composition (IME) Handling
43
+ ///
44
+ /// For CJK input methods, `setMarkedText` / `unmarkText` are used. During
45
+ /// composition (marked text), we let UITextView handle it normally so the
46
+ /// user sees their composing text. When composition finalizes (`unmarkText`),
47
+ /// we capture the result and route it through Rust.
48
+ ///
49
+ /// ## Thread Safety
50
+ ///
51
+ /// All UITextView methods are called on the main thread. The UniFFI calls
52
+ /// (`editor_insert_text`, `editor_delete_range`, etc.) are synchronous and
53
+ /// fast enough for main-thread use. If profiling shows otherwise, we can
54
+ /// dispatch to a serial queue and batch updates.
55
+ final class EditorTextView: UITextView, UITextViewDelegate {
56
+
57
+ // MARK: - Properties
58
+
59
+ /// The Rust editor instance ID (from editor_create / editor_create_with_max_length).
60
+ /// Set to 0 when no editor is bound.
61
+ var editorId: UInt64 = 0
62
+
63
+ /// Guard flag to prevent re-entrant input interception while we're
64
+ /// applying state from Rust (calling replaceCharacters on the text storage).
65
+ var isApplyingRustState = false
66
+
67
+ /// The base font used for unstyled text. Configurable from React props.
68
+ var baseFont: UIFont = .systemFont(ofSize: 16)
69
+
70
+ /// The base text color. Configurable from React props.
71
+ var baseTextColor: UIColor = .label
72
+
73
+ /// The base background color before theme overrides.
74
+ var baseBackgroundColor: UIColor = .systemBackground
75
+ var baseTextContainerInset: UIEdgeInsets = .zero
76
+
77
+ /// Optional render theme supplied by React.
78
+ var theme: EditorTheme? {
79
+ didSet {
80
+ placeholderLabel.font = resolvedDefaultFont()
81
+ backgroundColor = theme?.backgroundColor ?? baseBackgroundColor
82
+ if let contentInsets = theme?.contentInsets {
83
+ textContainerInset = UIEdgeInsets(
84
+ top: contentInsets.top ?? 0,
85
+ left: contentInsets.left ?? 0,
86
+ bottom: contentInsets.bottom ?? 0,
87
+ right: contentInsets.right ?? 0
88
+ )
89
+ } else {
90
+ textContainerInset = baseTextContainerInset
91
+ }
92
+ }
93
+ }
94
+
95
+ var heightBehavior: EditorHeightBehavior = .fixed {
96
+ didSet {
97
+ guard oldValue != heightBehavior else { return }
98
+ isScrollEnabled = heightBehavior == .fixed
99
+ invalidateIntrinsicContentSize()
100
+ notifyHeightChangeIfNeeded(force: true)
101
+ }
102
+ }
103
+
104
+ var onHeightMayChange: (() -> Void)?
105
+ private var lastAutoGrowMeasuredHeight: CGFloat = 0
106
+
107
+ /// Delegate for editor events.
108
+ weak var editorDelegate: EditorTextViewDelegate?
109
+
110
+ /// The plain text from the last Rust render, used by the reconciliation
111
+ /// fallback to detect unauthorized text storage mutations.
112
+ private(set) var lastAuthorizedText: String = ""
113
+
114
+ /// Number of times the reconciliation fallback has fired. Exposed for
115
+ /// monitoring / kill-condition telemetry.
116
+ private(set) var reconciliationCount: Int = 0
117
+
118
+ /// Logger for reconciliation events (visible in Console.app / device logs).
119
+ private static let reconciliationLog = Logger(
120
+ subsystem: "com.apollohg.prose-editor",
121
+ category: "reconciliation"
122
+ )
123
+ private static let inputLog = Logger(
124
+ subsystem: "com.apollohg.prose-editor",
125
+ category: "input"
126
+ )
127
+ private static let updateLog = Logger(
128
+ subsystem: "com.apollohg.prose-editor",
129
+ category: "update"
130
+ )
131
+ private static let selectionLog = Logger(
132
+ subsystem: "com.apollohg.prose-editor",
133
+ category: "selection"
134
+ )
135
+
136
+ /// Tracks whether we're in a composition session (CJK / IME input).
137
+ private var isComposing = false
138
+
139
+ /// Guards against reconciliation firing while we're already intercepting
140
+ /// and replaying a user input operation through Rust, including the
141
+ /// trailing UIKit text-storage callbacks that arrive on the next run loop.
142
+ private var interceptedInputDepth = 0
143
+
144
+ /// Coalesces selection sync until UIKit has finished resolving the
145
+ /// current tap/drag gesture's final caret position.
146
+ private var pendingSelectionSyncGeneration: UInt64 = 0
147
+
148
+ /// Stores the text that was composed during a marked text session,
149
+ /// captured when `unmarkText` is called.
150
+ private var composedText: String?
151
+
152
+ private let editorLayoutManager: EditorLayoutManager
153
+
154
+ // MARK: - Placeholder
155
+
156
+ private lazy var placeholderLabel: UILabel = {
157
+ let label = UILabel()
158
+ label.textColor = .placeholderText
159
+ label.font = baseFont
160
+ label.numberOfLines = 0
161
+ label.isUserInteractionEnabled = false
162
+ return label
163
+ }()
164
+
165
+ var placeholder: String = "" {
166
+ didSet { placeholderLabel.text = placeholder }
167
+ }
168
+
169
+ // MARK: - Initialization
170
+
171
+ override init(frame: CGRect, textContainer: NSTextContainer?) {
172
+ let layoutManager = EditorLayoutManager()
173
+ let container = textContainer ?? NSTextContainer(size: .zero)
174
+ let textStorage = NSTextStorage()
175
+ layoutManager.addTextContainer(container)
176
+ textStorage.addLayoutManager(layoutManager)
177
+ editorLayoutManager = layoutManager
178
+ super.init(frame: frame, textContainer: container)
179
+ commonInit()
180
+ }
181
+
182
+ required init?(coder: NSCoder) {
183
+ return nil
184
+ }
185
+
186
+ private func commonInit() {
187
+ textContainer.widthTracksTextView = true
188
+ editorLayoutManager.allowsNonContiguousLayout = false
189
+
190
+ // Configure the text view as a Rust-controlled editor surface.
191
+ // UIKit smart-edit features mutate text storage outside our transaction
192
+ // pipeline and can race with stored-mark typing after toolbar actions.
193
+ autocorrectionType = .no
194
+ autocapitalizationType = .sentences
195
+ spellCheckingType = .no
196
+ smartQuotesType = .no
197
+ smartDashesType = .no
198
+ smartInsertDeleteType = .no
199
+
200
+ // Allow scrolling and text selection.
201
+ isScrollEnabled = heightBehavior == .fixed
202
+ isEditable = true
203
+ isSelectable = true
204
+
205
+ // Set a reasonable default font.
206
+ font = baseFont
207
+ textColor = baseTextColor
208
+ backgroundColor = baseBackgroundColor
209
+ baseTextContainerInset = textContainerInset
210
+
211
+ // Register as the text storage delegate so we can detect unauthorized
212
+ // mutations (reconciliation fallback).
213
+ textStorage.delegate = self
214
+ delegate = self
215
+
216
+ addSubview(placeholderLabel)
217
+ }
218
+
219
+ // MARK: - Layout
220
+
221
+ override func layoutSubviews() {
222
+ super.layoutSubviews()
223
+ placeholderLabel.frame = CGRect(
224
+ x: textContainerInset.left + textContainer.lineFragmentPadding,
225
+ y: textContainerInset.top,
226
+ width: bounds.width - textContainerInset.left - textContainerInset.right - 2 * textContainer.lineFragmentPadding,
227
+ height: bounds.height - textContainerInset.top - textContainerInset.bottom
228
+ )
229
+ if heightBehavior == .autoGrow {
230
+ notifyHeightChangeIfNeeded()
231
+ }
232
+ }
233
+
234
+ override func caretRect(for position: UITextPosition) -> CGRect {
235
+ let rect = super.caretRect(for: position)
236
+ guard rect.height > 0 else { return rect }
237
+
238
+ let caretFont = resolvedCaretFont(for: position)
239
+ let screenScale = window?.screen.scale ?? UIScreen.main.scale
240
+ let targetHeight = ceil(caretFont.lineHeight)
241
+ guard targetHeight > 0, targetHeight < rect.height else { return rect }
242
+
243
+ if let baselineY = caretBaselineY(for: position, referenceRect: rect) {
244
+ return Self.adjustedCaretRect(
245
+ from: rect,
246
+ baselineY: baselineY,
247
+ font: caretFont,
248
+ screenScale: screenScale
249
+ )
250
+ }
251
+
252
+ return Self.adjustedCaretRect(
253
+ from: rect,
254
+ font: caretFont,
255
+ screenScale: screenScale
256
+ )
257
+ }
258
+
259
+ // MARK: - Editor Binding
260
+
261
+ /// Bind this text view to a Rust editor instance and apply initial content.
262
+ ///
263
+ /// - Parameters:
264
+ /// - id: The editor ID from `editor_create()`.
265
+ /// - initialHTML: Optional HTML to set as initial content.
266
+ func bindEditor(id: UInt64, initialHTML: String? = nil) {
267
+ editorId = id
268
+
269
+ if let html = initialHTML, !html.isEmpty {
270
+ let renderJSON = editorSetHtml(id: editorId, html: html)
271
+ applyRenderJSON(renderJSON)
272
+ } else {
273
+ // Pull current state from Rust (content may already be loaded via bridge).
274
+ let stateJSON = editorGetCurrentState(id: editorId)
275
+ applyUpdateJSON(stateJSON)
276
+ }
277
+ }
278
+
279
+ /// Unbind from the current editor instance.
280
+ func unbindEditor() {
281
+ editorId = 0
282
+ }
283
+
284
+ // MARK: - Input Interception: Text Insertion
285
+
286
+ /// Intercept text insertion. This is called for:
287
+ /// - Single character typing (including autocomplete insertions)
288
+ /// - Return/Enter key
289
+ /// - Dictation results
290
+ ///
291
+ /// Instead of calling `super.insertText()` (which would modify the
292
+ /// underlying text storage directly), we route through Rust.
293
+ override func insertText(_ text: String) {
294
+ guard !isApplyingRustState else {
295
+ super.insertText(text)
296
+ return
297
+ }
298
+ guard editorId != 0 else {
299
+ super.insertText(text)
300
+ return
301
+ }
302
+
303
+ // Handle Enter/Return as a block split operation.
304
+ if text == "\n" {
305
+ performInterceptedInput {
306
+ handleReturnKey()
307
+ }
308
+ return
309
+ }
310
+
311
+ // Get the current cursor position as a scalar offset.
312
+ let scalarPos = PositionBridge.cursorScalarOffset(in: self)
313
+ Self.inputLog.debug(
314
+ "[insertText] text=\(self.preview(text), privacy: .public) scalarPos=\(scalarPos) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
315
+ )
316
+
317
+ // If there's a range selection, atomically replace it.
318
+ if let selectedRange = selectedTextRange, !selectedRange.isEmpty {
319
+ let range = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
320
+ performInterceptedInput {
321
+ let updateJSON = editorReplaceTextScalar(
322
+ id: editorId,
323
+ scalarFrom: range.from,
324
+ scalarTo: range.to,
325
+ text: text
326
+ )
327
+ applyUpdateJSON(updateJSON)
328
+ }
329
+ } else {
330
+ performInterceptedInput {
331
+ insertTextInRust(text, at: scalarPos)
332
+ }
333
+ }
334
+ }
335
+
336
+ override var keyCommands: [UIKeyCommand]? {
337
+ [
338
+ UIKeyCommand(
339
+ input: "\r",
340
+ modifierFlags: [.shift],
341
+ action: #selector(handleHardBreakKeyCommand)
342
+ ),
343
+ UIKeyCommand(
344
+ input: "\t",
345
+ modifierFlags: [],
346
+ action: #selector(handleIndentKeyCommand)
347
+ ),
348
+ UIKeyCommand(
349
+ input: "\t",
350
+ modifierFlags: [.shift],
351
+ action: #selector(handleOutdentKeyCommand)
352
+ ),
353
+ ]
354
+ }
355
+
356
+ @objc private func handleIndentKeyCommand() {
357
+ handleListDepthKeyCommand(outdent: false)
358
+ }
359
+
360
+ @objc private func handleHardBreakKeyCommand() {
361
+ performInterceptedInput {
362
+ insertNodeInRust("hardBreak")
363
+ }
364
+ }
365
+
366
+ @objc private func handleOutdentKeyCommand() {
367
+ handleListDepthKeyCommand(outdent: true)
368
+ }
369
+
370
+ // MARK: - Input Interception: Deletion
371
+
372
+ /// Intercept backward deletion (Backspace key).
373
+ ///
374
+ /// If there's a range selection, delete the range. If it's a cursor,
375
+ /// delete the character (grapheme cluster) before the cursor.
376
+ override func deleteBackward() {
377
+ guard !isApplyingRustState else {
378
+ super.deleteBackward()
379
+ return
380
+ }
381
+ guard editorId != 0 else {
382
+ super.deleteBackward()
383
+ return
384
+ }
385
+
386
+ guard let selectedRange = selectedTextRange else { return }
387
+ Self.inputLog.debug(
388
+ "[deleteBackward] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
389
+ )
390
+
391
+ if !selectedRange.isEmpty {
392
+ // Range selection: delete the entire range.
393
+ let range = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
394
+ performInterceptedInput {
395
+ deleteScalarRangeInRust(from: range.from, to: range.to)
396
+ }
397
+ } else {
398
+ // Cursor: delete one grapheme cluster backward.
399
+ let cursorPos = PositionBridge.textViewToScalar(selectedRange.start, in: self)
400
+ guard cursorPos > 0 else { return }
401
+
402
+ let cursorUtf16Offset = offset(from: beginningOfDocument, to: selectedRange.start)
403
+ if let marker = PositionBridge.virtualListMarker(
404
+ atUtf16Offset: cursorUtf16Offset,
405
+ in: self
406
+ ), marker.paragraphStartUtf16 == cursorUtf16Offset {
407
+ performInterceptedInput {
408
+ deleteScalarRangeInRust(from: cursorPos - 1, to: cursorPos)
409
+ }
410
+ return
411
+ }
412
+
413
+ if let deleteRange = trailingHorizontalRuleDeleteRangeForBackwardDelete(
414
+ cursorUtf16Offset: cursorUtf16Offset
415
+ ) {
416
+ performInterceptedInput {
417
+ deleteScalarRangeInRust(from: deleteRange.from, to: deleteRange.to)
418
+ }
419
+ return
420
+ }
421
+
422
+ if let deleteRange = adjacentVoidBlockDeleteRangeForBackwardDelete(
423
+ cursorUtf16Offset: cursorUtf16Offset,
424
+ cursorScalar: cursorPos
425
+ ) {
426
+ performInterceptedInput {
427
+ deleteScalarRangeInRust(from: deleteRange.from, to: deleteRange.to)
428
+ }
429
+ return
430
+ }
431
+
432
+ // Find the start of the previous grapheme cluster.
433
+ // We need to figure out how many scalars the previous grapheme occupies.
434
+ let utf16Offset = offset(from: beginningOfDocument, to: selectedRange.start)
435
+ if utf16Offset <= 0 { return }
436
+
437
+ // Use UITextView's tokenizer to find the previous grapheme boundary.
438
+ guard let prevPos = position(from: selectedRange.start, offset: -1) else { return }
439
+ let prevScalar = PositionBridge.textViewToScalar(prevPos, in: self)
440
+
441
+ performInterceptedInput {
442
+ deleteScalarRangeInRust(from: prevScalar, to: cursorPos)
443
+ }
444
+ }
445
+ }
446
+
447
+ private func adjacentVoidBlockDeleteRangeForBackwardDelete(
448
+ cursorUtf16Offset: Int,
449
+ cursorScalar: UInt32
450
+ ) -> (from: UInt32, to: UInt32)? {
451
+ guard cursorUtf16Offset >= 0, cursorUtf16Offset < textStorage.length else {
452
+ return nil
453
+ }
454
+ let attrs = textStorage.attributes(at: cursorUtf16Offset, effectiveRange: nil)
455
+ guard attrs[.attachment] is NSTextAttachment,
456
+ attrs[RenderBridgeAttributes.voidNodeType] as? String != nil,
457
+ cursorScalar < UInt32.max
458
+ else {
459
+ return nil
460
+ }
461
+ return (from: cursorScalar, to: cursorScalar + 1)
462
+ }
463
+
464
+ private func trailingHorizontalRuleDeleteRangeForBackwardDelete(
465
+ cursorUtf16Offset: Int
466
+ ) -> (from: UInt32, to: UInt32)? {
467
+ let text = textStorage.string as NSString
468
+ guard text.length > 0 else { return nil }
469
+
470
+ let clampedCursor = min(max(cursorUtf16Offset, 0), text.length)
471
+ let paragraphProbe = min(max(clampedCursor - 1, 0), text.length - 1)
472
+ let paragraphRange = text.paragraphRange(for: NSRange(location: paragraphProbe, length: 0))
473
+
474
+ let placeholderRange = NSRange(location: paragraphRange.location, length: 1)
475
+ guard placeholderRange.location + placeholderRange.length <= text.length else {
476
+ return nil
477
+ }
478
+
479
+ let paragraphText = text.substring(with: placeholderRange)
480
+ guard paragraphText == "\u{200B}" else { return nil }
481
+ guard paragraphRange.location >= 2 else { return nil }
482
+ guard text.character(at: paragraphRange.location - 1) == 0x000A else { return nil }
483
+
484
+ let attachmentIndex = paragraphRange.location - 2
485
+ let attrs = textStorage.attributes(at: attachmentIndex, effectiveRange: nil)
486
+ guard attrs[.attachment] is NSTextAttachment,
487
+ attrs[RenderBridgeAttributes.voidNodeType] as? String == "horizontalRule"
488
+ else {
489
+ return nil
490
+ }
491
+
492
+ let placeholderEndScalar = PositionBridge.utf16OffsetToScalar(
493
+ placeholderRange.location + placeholderRange.length,
494
+ in: self
495
+ )
496
+ guard placeholderEndScalar > 0 else { return nil }
497
+ return (from: placeholderEndScalar - 1, to: placeholderEndScalar)
498
+ }
499
+
500
+ private func handleListDepthKeyCommand(outdent: Bool) {
501
+ guard !isApplyingRustState else { return }
502
+ guard editorId != 0 else { return }
503
+ guard isEditable else { return }
504
+ guard isCaretInsideList() else { return }
505
+ guard let selection = currentScalarSelection() else { return }
506
+
507
+ performInterceptedInput {
508
+ let updateJSON = outdent
509
+ ? editorOutdentListItemAtSelectionScalar(
510
+ id: editorId,
511
+ scalarAnchor: selection.anchor,
512
+ scalarHead: selection.head
513
+ )
514
+ : editorIndentListItemAtSelectionScalar(
515
+ id: editorId,
516
+ scalarAnchor: selection.anchor,
517
+ scalarHead: selection.head
518
+ )
519
+ applyUpdateJSON(updateJSON)
520
+ }
521
+ }
522
+
523
+ private func isCaretInsideList() -> Bool {
524
+ guard editorId != 0 else { return false }
525
+ guard
526
+ let data = editorGetCurrentState(id: editorId).data(using: .utf8),
527
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
528
+ let activeState = object["activeState"] as? [String: Any],
529
+ let nodes = activeState["nodes"] as? [String: Any]
530
+ else {
531
+ return false
532
+ }
533
+
534
+ return nodes["bulletList"] as? Bool == true || nodes["orderedList"] as? Bool == true
535
+ }
536
+
537
+ // MARK: - Input Interception: Replace (Autocorrect)
538
+
539
+ /// Intercept text replacement. This is called when:
540
+ /// - Autocorrect replaces a word
541
+ /// - User accepts a spelling suggestion
542
+ /// - Programmatic text replacement
543
+ ///
544
+ /// We route the replacement through Rust to keep the document model in sync.
545
+ override func replace(_ range: UITextRange, withText text: String) {
546
+ guard !isApplyingRustState else {
547
+ super.replace(range, withText: text)
548
+ return
549
+ }
550
+ guard editorId != 0 else {
551
+ super.replace(range, withText: text)
552
+ return
553
+ }
554
+
555
+ let scalarRange = PositionBridge.textRangeToScalarRange(range, in: self)
556
+ Self.inputLog.debug(
557
+ "[replace] text=\(self.preview(text), privacy: .public) scalarRange=\(scalarRange.from)-\(scalarRange.to) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
558
+ )
559
+
560
+ // Atomically replace the range with the new text via Rust.
561
+ performInterceptedInput {
562
+ let updateJSON = editorReplaceTextScalar(
563
+ id: editorId,
564
+ scalarFrom: scalarRange.from,
565
+ scalarTo: scalarRange.to,
566
+ text: text
567
+ )
568
+ applyUpdateJSON(updateJSON)
569
+ }
570
+ }
571
+
572
+ // MARK: - Composition Handling (CJK / IME)
573
+
574
+ /// Called when the input method sets marked (composing) text.
575
+ ///
576
+ /// During CJK input, the user composes characters incrementally. We let
577
+ /// UITextView display the composing text normally (with its underline
578
+ /// decoration). The text is NOT sent to Rust during composition.
579
+ override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
580
+ isComposing = true
581
+ Self.inputLog.debug(
582
+ "[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
583
+ )
584
+ super.setMarkedText(markedText, selectedRange: selectedRange)
585
+ }
586
+
587
+ /// Called when composition is finalized (user selects a candidate or
588
+ /// presses space/enter to commit).
589
+ ///
590
+ /// At this point, the composed text is final. We capture it and send
591
+ /// it to Rust as a single insertion. `unmarkText` in UITextView will
592
+ /// replace the marked text with the final text in the text storage,
593
+ /// but we intercept at a higher level.
594
+ override func unmarkText() {
595
+ // Capture the finalized composed text before UIKit clears it.
596
+ composedText = markedTextRange.flatMap { text(in: $0) }
597
+
598
+ // Prevent selection sync while UIKit commits the marked text, since
599
+ // the Rust document doesn't have the composed text yet.
600
+ isApplyingRustState = true
601
+ super.unmarkText()
602
+ isApplyingRustState = false
603
+ isComposing = false
604
+
605
+ // Now route the composed text through Rust. The cursor is at the end
606
+ // of the composed text, so the insert position is cursor - length.
607
+ if let composed = composedText, !composed.isEmpty, editorId != 0 {
608
+ let cursorPos = PositionBridge.cursorScalarOffset(in: self)
609
+ let composedScalars = UInt32(composed.unicodeScalars.count)
610
+ let insertPos = cursorPos >= composedScalars ? cursorPos - composedScalars : 0
611
+ Self.inputLog.debug(
612
+ "[unmarkText] composed=\(self.preview(composed), privacy: .public) cursorPos=\(cursorPos) insertPos=\(insertPos) selection=\(self.selectionSummary(), privacy: .public)"
613
+ )
614
+ performInterceptedInput {
615
+ insertTextInRust(composed, at: insertPos)
616
+ }
617
+ }
618
+ composedText = nil
619
+ }
620
+
621
+ // MARK: - Paste Handling
622
+
623
+ /// Intercept paste operations to route content through Rust.
624
+ ///
625
+ /// Attempts to extract HTML from the pasteboard first (for rich text paste),
626
+ /// falling back to plain text.
627
+ override func paste(_ sender: Any?) {
628
+ guard editorId != 0 else {
629
+ super.paste(sender)
630
+ return
631
+ }
632
+
633
+ Self.inputLog.debug(
634
+ "[paste] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
635
+ )
636
+
637
+ let pasteboard = UIPasteboard.general
638
+
639
+ // Try HTML first for rich paste.
640
+ if let htmlData = pasteboard.data(forPasteboardType: "public.html"),
641
+ let html = String(data: htmlData, encoding: .utf8) {
642
+ performInterceptedInput {
643
+ pasteHTML(html)
644
+ }
645
+ return
646
+ }
647
+
648
+ // Try attributed string (e.g. from Notes, Pages).
649
+ if let rtfData = pasteboard.data(forPasteboardType: "public.rtf") {
650
+ if let attrStr = try? NSAttributedString(
651
+ data: rtfData,
652
+ options: [.documentType: NSAttributedString.DocumentType.rtf],
653
+ documentAttributes: nil
654
+ ) {
655
+ // Convert attributed string to HTML for Rust processing.
656
+ if let htmlData = try? attrStr.data(
657
+ from: NSRange(location: 0, length: attrStr.length),
658
+ documentAttributes: [.documentType: NSAttributedString.DocumentType.html]
659
+ ), let html = String(data: htmlData, encoding: .utf8) {
660
+ performInterceptedInput {
661
+ pasteHTML(html)
662
+ }
663
+ return
664
+ }
665
+ }
666
+ }
667
+
668
+ // Fallback to plain text.
669
+ if let text = pasteboard.string {
670
+ performInterceptedInput {
671
+ pastePlainText(text)
672
+ }
673
+ return
674
+ }
675
+ }
676
+
677
+ // MARK: - Selection Change Notification
678
+
679
+ /// UITextViewDelegate hook for user-driven selection updates.
680
+ ///
681
+ /// Using the delegate callback is more reliable than observing
682
+ /// `selectedTextRange` directly because UIKit can adjust selection
683
+ /// internally during tap handling and word-boundary resolution.
684
+ func textViewDidChangeSelection(_ textView: UITextView) {
685
+ guard textView === self else { return }
686
+ scheduleSelectionSync()
687
+ }
688
+
689
+ // MARK: - Private: Rust Integration
690
+
691
+ private var isInterceptingInput: Bool {
692
+ interceptedInputDepth > 0
693
+ }
694
+
695
+ private func performInterceptedInput(_ action: () -> Void) {
696
+ interceptedInputDepth += 1
697
+ Self.inputLog.debug(
698
+ "[intercept.begin] depth=\(self.interceptedInputDepth) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
699
+ )
700
+ action()
701
+ DispatchQueue.main.async { [weak self] in
702
+ guard let self else { return }
703
+ self.interceptedInputDepth = max(0, self.interceptedInputDepth - 1)
704
+ Self.inputLog.debug(
705
+ "[intercept.end] depth=\(self.interceptedInputDepth) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
706
+ )
707
+ }
708
+ }
709
+
710
+ private func preview(_ text: String, limit: Int = 32) -> String {
711
+ let normalized = text.replacingOccurrences(of: "\n", with: "\\n")
712
+ if normalized.count <= limit {
713
+ return normalized
714
+ }
715
+ return "\(normalized.prefix(limit))…"
716
+ }
717
+
718
+ private func textSnapshotSummary() -> String {
719
+ let text = textStorage.string
720
+ return "len=\(text.count) preview=\"\(preview(text))\""
721
+ }
722
+
723
+ private func selectionSummary() -> String {
724
+ guard let range = selectedTextRange else { return "none" }
725
+ let anchorScalar = PositionBridge.textViewToScalar(range.start, in: self)
726
+ let headScalar = PositionBridge.textViewToScalar(range.end, in: self)
727
+ guard editorId != 0 else {
728
+ return "scalar=\(anchorScalar)-\(headScalar)"
729
+ }
730
+ let docAnchor = editorScalarToDoc(id: editorId, scalar: anchorScalar)
731
+ let docHead = editorScalarToDoc(id: editorId, scalar: headScalar)
732
+ return "scalar=\(anchorScalar)-\(headScalar) doc=\(docAnchor)-\(docHead)"
733
+ }
734
+
735
+ private func selectionSummary(from selection: [String: Any]) -> String {
736
+ guard let type = selection["type"] as? String else { return "unknown" }
737
+ switch type {
738
+ case "text":
739
+ let anchor = (selection["anchor"] as? NSNumber)?.uint32Value ?? 0
740
+ let head = (selection["head"] as? NSNumber)?.uint32Value ?? 0
741
+ return "text doc=\(anchor)-\(head)"
742
+ case "node":
743
+ let pos = (selection["pos"] as? NSNumber)?.uint32Value ?? 0
744
+ return "node doc=\(pos)"
745
+ case "all":
746
+ return "all"
747
+ default:
748
+ return type
749
+ }
750
+ }
751
+
752
+ private func refreshTypingAttributesForSelection() {
753
+ guard let range = selectedTextRange else {
754
+ typingAttributes = defaultTypingAttributes()
755
+ return
756
+ }
757
+
758
+ if textStorage.length == 0 {
759
+ typingAttributes = defaultTypingAttributes()
760
+ return
761
+ }
762
+
763
+ let startOffset = offset(from: beginningOfDocument, to: range.start)
764
+ let attributeIndex: Int
765
+ if startOffset < textStorage.length {
766
+ attributeIndex = max(0, startOffset)
767
+ } else {
768
+ attributeIndex = textStorage.length - 1
769
+ }
770
+
771
+ var attrs = textStorage.attributes(at: attributeIndex, effectiveRange: nil)
772
+ attrs[.font] = attrs[.font] ?? resolvedDefaultFont()
773
+ attrs[.foregroundColor] = attrs[.foregroundColor] ?? resolvedDefaultTextColor()
774
+ typingAttributes = attrs
775
+ }
776
+
777
+ private func scheduleSelectionSync() {
778
+ pendingSelectionSyncGeneration &+= 1
779
+ let generation = pendingSelectionSyncGeneration
780
+ DispatchQueue.main.async { [weak self] in
781
+ guard let self else { return }
782
+ guard self.pendingSelectionSyncGeneration == generation else { return }
783
+ self.syncSelectionToRustAndNotifyDelegate()
784
+ }
785
+ }
786
+
787
+ private func syncSelectionToRustAndNotifyDelegate() {
788
+ guard !isApplyingRustState, editorId != 0 else { return }
789
+ guard let range = selectedTextRange else { return }
790
+
791
+ let anchor = PositionBridge.textViewToScalar(range.start, in: self)
792
+ let head = PositionBridge.textViewToScalar(range.end, in: self)
793
+ let docAnchor = editorScalarToDoc(id: editorId, scalar: anchor)
794
+ let docHead = editorScalarToDoc(id: editorId, scalar: head)
795
+ Self.selectionLog.debug(
796
+ "[textViewDidChangeSelection] scalar=\(anchor)-\(head) doc=\(docAnchor)-\(docHead) textState=\(self.textSnapshotSummary(), privacy: .public)"
797
+ )
798
+
799
+ editorSetSelectionScalar(id: editorId, scalarAnchor: anchor, scalarHead: head)
800
+ refreshTypingAttributesForSelection()
801
+ editorDelegate?.editorTextView(self, selectionDidChange: docAnchor, head: docHead)
802
+ }
803
+
804
+ func applyTheme(_ theme: EditorTheme?) {
805
+ self.theme = theme
806
+ if editorId != 0 {
807
+ let previousOffset = contentOffset
808
+ let stateJSON = editorGetCurrentState(id: editorId)
809
+ applyUpdateJSON(stateJSON, notifyDelegate: false)
810
+ if heightBehavior == .fixed {
811
+ preserveScrollOffset(previousOffset)
812
+ }
813
+ } else {
814
+ refreshTypingAttributesForSelection()
815
+ }
816
+ if heightBehavior == .autoGrow {
817
+ notifyHeightChangeIfNeeded(force: true)
818
+ }
819
+ }
820
+
821
+ private func preserveScrollOffset(_ previousOffset: CGPoint) {
822
+ let restore = { [weak self] in
823
+ guard let self else { return }
824
+ self.layoutIfNeeded()
825
+
826
+ let maxOffsetX = max(
827
+ -self.adjustedContentInset.left,
828
+ self.contentSize.width - self.bounds.width + self.adjustedContentInset.right
829
+ )
830
+ let maxOffsetY = max(
831
+ -self.adjustedContentInset.top,
832
+ self.contentSize.height - self.bounds.height + self.adjustedContentInset.bottom
833
+ )
834
+
835
+ let clampedOffset = CGPoint(
836
+ x: min(max(previousOffset.x, -self.adjustedContentInset.left), maxOffsetX),
837
+ y: min(max(previousOffset.y, -self.adjustedContentInset.top), maxOffsetY)
838
+ )
839
+ self.setContentOffset(clampedOffset, animated: false)
840
+ }
841
+
842
+ restore()
843
+ DispatchQueue.main.async(execute: restore)
844
+ }
845
+
846
+ private func defaultTypingAttributes() -> [NSAttributedString.Key: Any] {
847
+ [
848
+ .font: resolvedDefaultFont(),
849
+ .foregroundColor: resolvedDefaultTextColor(),
850
+ ]
851
+ }
852
+
853
+ private func resolvedDefaultFont() -> UIFont {
854
+ theme?.effectiveTextStyle(for: "paragraph").resolvedFont(fallback: baseFont)
855
+ ?? baseFont
856
+ }
857
+
858
+ private func resolvedDefaultTextColor() -> UIColor {
859
+ theme?.effectiveTextStyle(for: "paragraph").color ?? baseTextColor
860
+ }
861
+
862
+ private func notifyHeightChangeIfNeeded(force: Bool = false) {
863
+ guard heightBehavior == .autoGrow else { return }
864
+ let width = bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width
865
+ guard width > 0 else { return }
866
+ let measuredHeight = ceil(
867
+ sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)).height
868
+ )
869
+ guard force || abs(measuredHeight - lastAutoGrowMeasuredHeight) > 0.5 else { return }
870
+ lastAutoGrowMeasuredHeight = measuredHeight
871
+ onHeightMayChange?()
872
+ }
873
+
874
+ static func adjustedCaretRect(
875
+ from rect: CGRect,
876
+ targetHeight: CGFloat,
877
+ screenScale: CGFloat
878
+ ) -> CGRect {
879
+ guard rect.height > 0, targetHeight > 0, targetHeight < rect.height else {
880
+ return rect
881
+ }
882
+
883
+ let scale = max(screenScale, 1)
884
+ let alignedHeight = ceil(targetHeight * scale) / scale
885
+ let centeredY = rect.minY + ((rect.height - alignedHeight) / 2.0)
886
+ let alignedY = (centeredY * scale).rounded() / scale
887
+
888
+ var adjusted = rect
889
+ adjusted.origin.y = alignedY
890
+ adjusted.size.height = alignedHeight
891
+ return adjusted
892
+ }
893
+
894
+ static func adjustedCaretRect(
895
+ from rect: CGRect,
896
+ font: UIFont,
897
+ screenScale: CGFloat
898
+ ) -> CGRect {
899
+ let scale = max(screenScale, 1)
900
+ let lineHeight = max(font.lineHeight, 0)
901
+ let alignedHeight = ceil(lineHeight * scale) / scale
902
+ let alignedY = ((rect.maxY - alignedHeight) * scale).rounded() / scale
903
+
904
+ var adjusted = rect
905
+ adjusted.origin.y = alignedY
906
+ adjusted.size.height = alignedHeight
907
+ return adjusted
908
+ }
909
+
910
+ static func adjustedCaretRect(
911
+ from rect: CGRect,
912
+ baselineY: CGFloat,
913
+ font: UIFont,
914
+ screenScale: CGFloat
915
+ ) -> CGRect {
916
+ let scale = max(screenScale, 1)
917
+ let lineHeight = max(font.lineHeight, 0)
918
+ let alignedHeight = ceil(lineHeight * scale) / scale
919
+ let typographicHeight = font.ascender - font.descender
920
+ let leading = max(lineHeight - typographicHeight, 0)
921
+ let topY = baselineY - font.ascender - (leading / 2.0)
922
+ let alignedY = (topY * scale).rounded() / scale
923
+
924
+ var adjusted = rect
925
+ adjusted.origin.y = alignedY
926
+ adjusted.size.height = alignedHeight
927
+ return adjusted
928
+ }
929
+
930
+ private func caretBaselineY(for position: UITextPosition, referenceRect: CGRect) -> CGFloat? {
931
+ guard textStorage.length > 0 else { return nil }
932
+
933
+ let rawOffset = offset(from: beginningOfDocument, to: position)
934
+ let clampedOffset = min(max(rawOffset, 0), textStorage.length)
935
+ var candidateCharacters = Set<Int>()
936
+
937
+ if clampedOffset < textStorage.length {
938
+ candidateCharacters.insert(clampedOffset)
939
+ }
940
+ if clampedOffset > 0 {
941
+ candidateCharacters.insert(clampedOffset - 1)
942
+ }
943
+ if clampedOffset + 1 < textStorage.length {
944
+ candidateCharacters.insert(clampedOffset + 1)
945
+ }
946
+
947
+ guard !candidateCharacters.isEmpty else { return nil }
948
+
949
+ let referenceMidY = referenceRect.midY
950
+ let referenceMinY = referenceRect.minY
951
+ var bestMatch: (score: CGFloat, baselineY: CGFloat)?
952
+
953
+ for characterIndex in candidateCharacters.sorted() {
954
+ let glyphIndex = layoutManager.glyphIndexForCharacter(at: characterIndex)
955
+ guard glyphIndex < layoutManager.numberOfGlyphs else { continue }
956
+
957
+ let lineFragmentRect = layoutManager.lineFragmentRect(
958
+ forGlyphAt: glyphIndex,
959
+ effectiveRange: nil
960
+ )
961
+ let lineRectInView = lineFragmentRect.offsetBy(dx: 0, dy: textContainerInset.top)
962
+ let score = abs(lineRectInView.midY - referenceMidY) * 10
963
+ + abs(lineRectInView.minY - referenceMinY)
964
+ let glyphLocation = layoutManager.location(forGlyphAt: glyphIndex)
965
+ let baselineY = textContainerInset.top + lineFragmentRect.minY + glyphLocation.y
966
+
967
+ if let currentBest = bestMatch, currentBest.score <= score {
968
+ continue
969
+ }
970
+ bestMatch = (score, baselineY)
971
+ }
972
+
973
+ return bestMatch?.baselineY
974
+ }
975
+
976
+ private func resolvedCaretFont(for position: UITextPosition) -> UIFont {
977
+ guard textStorage.length > 0 else { return resolvedDefaultFont() }
978
+
979
+ let offset = offset(from: beginningOfDocument, to: position)
980
+ let attributeIndex: Int
981
+ if offset <= 0 {
982
+ attributeIndex = 0
983
+ } else if offset < textStorage.length {
984
+ attributeIndex = offset
985
+ } else {
986
+ attributeIndex = textStorage.length - 1
987
+ }
988
+
989
+ return (textStorage.attribute(.font, at: attributeIndex, effectiveRange: nil) as? UIFont)
990
+ ?? resolvedDefaultFont()
991
+ }
992
+
993
+ func performToolbarToggleMark(_ markName: String) {
994
+ guard editorId != 0 else { return }
995
+ guard isEditable else { return }
996
+ guard let selection = currentScalarSelection() else { return }
997
+ performInterceptedInput {
998
+ let updateJSON = editorToggleMarkAtSelectionScalar(
999
+ id: editorId,
1000
+ scalarAnchor: selection.anchor,
1001
+ scalarHead: selection.head,
1002
+ markName: markName
1003
+ )
1004
+ applyUpdateJSON(updateJSON)
1005
+ }
1006
+ }
1007
+
1008
+ func performToolbarToggleList(_ listType: String, isActive: Bool) {
1009
+ guard editorId != 0 else { return }
1010
+ guard isEditable else { return }
1011
+ guard let selection = currentScalarSelection() else { return }
1012
+ performInterceptedInput {
1013
+ let updateJSON = isActive
1014
+ ? editorUnwrapFromListAtSelectionScalar(
1015
+ id: editorId,
1016
+ scalarAnchor: selection.anchor,
1017
+ scalarHead: selection.head
1018
+ )
1019
+ : editorWrapInListAtSelectionScalar(
1020
+ id: editorId,
1021
+ scalarAnchor: selection.anchor,
1022
+ scalarHead: selection.head,
1023
+ listType: listType
1024
+ )
1025
+ applyUpdateJSON(updateJSON)
1026
+ }
1027
+ }
1028
+
1029
+ func performToolbarIndentListItem() {
1030
+ guard editorId != 0 else { return }
1031
+ guard isEditable else { return }
1032
+ guard let selection = currentScalarSelection() else { return }
1033
+ performInterceptedInput {
1034
+ let updateJSON = editorIndentListItemAtSelectionScalar(
1035
+ id: editorId,
1036
+ scalarAnchor: selection.anchor,
1037
+ scalarHead: selection.head
1038
+ )
1039
+ applyUpdateJSON(updateJSON)
1040
+ }
1041
+ }
1042
+
1043
+ func performToolbarOutdentListItem() {
1044
+ guard editorId != 0 else { return }
1045
+ guard isEditable else { return }
1046
+ guard let selection = currentScalarSelection() else { return }
1047
+ performInterceptedInput {
1048
+ let updateJSON = editorOutdentListItemAtSelectionScalar(
1049
+ id: editorId,
1050
+ scalarAnchor: selection.anchor,
1051
+ scalarHead: selection.head
1052
+ )
1053
+ applyUpdateJSON(updateJSON)
1054
+ }
1055
+ }
1056
+
1057
+ func performToolbarInsertNode(_ nodeType: String) {
1058
+ guard editorId != 0 else { return }
1059
+ guard isEditable else { return }
1060
+ performInterceptedInput {
1061
+ insertNodeInRust(nodeType)
1062
+ }
1063
+ }
1064
+
1065
+ func performToolbarUndo() {
1066
+ guard editorId != 0 else { return }
1067
+ guard isEditable else { return }
1068
+ performInterceptedInput {
1069
+ let updateJSON = editorUndo(id: editorId)
1070
+ applyUpdateJSON(updateJSON)
1071
+ }
1072
+ }
1073
+
1074
+ func performToolbarRedo() {
1075
+ guard editorId != 0 else { return }
1076
+ guard isEditable else { return }
1077
+ performInterceptedInput {
1078
+ let updateJSON = editorRedo(id: editorId)
1079
+ applyUpdateJSON(updateJSON)
1080
+ }
1081
+ }
1082
+
1083
+ /// Insert text at a scalar position via the Rust editor.
1084
+ private func insertTextInRust(_ text: String, at scalarPos: UInt32) {
1085
+ Self.inputLog.debug(
1086
+ "[rust.insertTextScalar] text=\(self.preview(text), privacy: .public) scalarPos=\(scalarPos) selection=\(self.selectionSummary(), privacy: .public)"
1087
+ )
1088
+ let updateJSON = editorInsertTextScalar(id: editorId, scalarPos: scalarPos, text: text)
1089
+ applyUpdateJSON(updateJSON)
1090
+ }
1091
+
1092
+ private func insertNodeInRust(_ nodeType: String) {
1093
+ guard let selection = currentScalarSelection() else { return }
1094
+ Self.inputLog.debug(
1095
+ "[rust.insertNode] nodeType=\(nodeType, privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
1096
+ )
1097
+ let updateJSON = editorInsertNodeAtSelectionScalar(
1098
+ id: editorId,
1099
+ scalarAnchor: selection.anchor,
1100
+ scalarHead: selection.head,
1101
+ nodeType: nodeType
1102
+ )
1103
+ applyUpdateJSON(updateJSON)
1104
+ }
1105
+
1106
+ /// Delete a scalar range via the Rust editor.
1107
+ private func deleteScalarRangeInRust(from: UInt32, to: UInt32) {
1108
+ guard from < to else { return }
1109
+ Self.inputLog.debug(
1110
+ "[rust.deleteScalarRange] scalar=\(from)-\(to) selection=\(self.selectionSummary(), privacy: .public)"
1111
+ )
1112
+ let updateJSON = editorDeleteScalarRange(id: editorId, scalarFrom: from, scalarTo: to)
1113
+ applyUpdateJSON(updateJSON)
1114
+ }
1115
+
1116
+ /// Delete a document-position range via the Rust editor.
1117
+ private func deleteRangeInRust(from: UInt32, to: UInt32) {
1118
+ guard from < to else { return }
1119
+ Self.inputLog.debug(
1120
+ "[rust.deleteRange] doc=\(from)-\(to) selection=\(self.selectionSummary(), privacy: .public)"
1121
+ )
1122
+ let updateJSON = editorDeleteRange(id: editorId, from: from, to: to)
1123
+ applyUpdateJSON(updateJSON)
1124
+ }
1125
+
1126
+ private func currentScalarSelection() -> (anchor: UInt32, head: UInt32)? {
1127
+ guard let range = selectedTextRange else { return nil }
1128
+ let scalarRange = PositionBridge.textRangeToScalarRange(range, in: self)
1129
+ return (anchor: scalarRange.from, head: scalarRange.to)
1130
+ }
1131
+
1132
+ /// Handle return key press as a block split operation.
1133
+ private func handleReturnKey() {
1134
+ // If there's a range selection, atomically delete and split.
1135
+ if let selectedRange = selectedTextRange, !selectedRange.isEmpty {
1136
+ let range = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
1137
+ let updateJSON = editorDeleteAndSplitScalar(
1138
+ id: editorId,
1139
+ scalarFrom: range.from,
1140
+ scalarTo: range.to
1141
+ )
1142
+ applyUpdateJSON(updateJSON)
1143
+ } else {
1144
+ let scalarPos = PositionBridge.cursorScalarOffset(in: self)
1145
+ splitBlockInRust(at: scalarPos)
1146
+ }
1147
+ }
1148
+
1149
+ /// Split a block at a scalar position via the Rust editor.
1150
+ private func splitBlockInRust(at scalarPos: UInt32) {
1151
+ Self.inputLog.debug(
1152
+ "[rust.splitBlockScalar] scalarPos=\(scalarPos) selection=\(self.selectionSummary(), privacy: .public)"
1153
+ )
1154
+ let updateJSON = editorSplitBlockScalar(id: editorId, scalarPos: scalarPos)
1155
+ applyUpdateJSON(updateJSON)
1156
+ }
1157
+
1158
+ /// Paste HTML content through Rust.
1159
+ private func pasteHTML(_ html: String) {
1160
+ Self.inputLog.debug(
1161
+ "[rust.pasteHTML] html=\(self.preview(html), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
1162
+ )
1163
+ let updateJSON = editorInsertContentHtml(id: editorId, html: html)
1164
+ applyUpdateJSON(updateJSON)
1165
+ }
1166
+
1167
+ /// Paste plain text through Rust.
1168
+ private func pastePlainText(_ text: String) {
1169
+ if let selectedRange = selectedTextRange, !selectedRange.isEmpty {
1170
+ // Atomically replace the selection with the pasted text.
1171
+ let range = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
1172
+ Self.inputLog.debug(
1173
+ "[rust.pastePlainText.replace] text=\(self.preview(text), privacy: .public) scalar=\(range.from)-\(range.to) selection=\(self.selectionSummary(), privacy: .public)"
1174
+ )
1175
+ let updateJSON = editorReplaceTextScalar(
1176
+ id: editorId,
1177
+ scalarFrom: range.from,
1178
+ scalarTo: range.to,
1179
+ text: text
1180
+ )
1181
+ applyUpdateJSON(updateJSON)
1182
+ } else {
1183
+ Self.inputLog.debug(
1184
+ "[rust.pastePlainText.insert] text=\(self.preview(text), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
1185
+ )
1186
+ insertTextInRust(text, at: PositionBridge.cursorScalarOffset(in: self))
1187
+ }
1188
+ }
1189
+
1190
+ // MARK: - Applying Rust State
1191
+
1192
+ /// Apply a full render update from Rust to the text view.
1193
+ ///
1194
+ /// Parses the update JSON, converts render elements to NSAttributedString
1195
+ /// via RenderBridge, and replaces the text view's content.
1196
+ ///
1197
+ /// - Parameter updateJSON: The JSON string from editor_insert_text, etc.
1198
+ func applyUpdateJSON(_ updateJSON: String, notifyDelegate: Bool = true) {
1199
+ guard let data = updateJSON.data(using: .utf8),
1200
+ let update = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
1201
+ else { return }
1202
+
1203
+ // Extract render elements.
1204
+ guard let renderElements = update["renderElements"] as? [[String: Any]] else { return }
1205
+ let selectionFromUpdate = (update["selection"] as? [String: Any])
1206
+ .map(self.selectionSummary(from:)) ?? "none"
1207
+ Self.updateLog.debug(
1208
+ "[applyUpdateJSON.begin] renderCount=\(renderElements.count) updateSelection=\(selectionFromUpdate, privacy: .public) before=\(self.textSnapshotSummary(), privacy: .public)"
1209
+ )
1210
+
1211
+ let attrStr = RenderBridge.renderElements(
1212
+ fromArray: renderElements,
1213
+ baseFont: baseFont,
1214
+ textColor: baseTextColor,
1215
+ theme: theme
1216
+ )
1217
+
1218
+ // Apply the attributed string without triggering input interception.
1219
+ isApplyingRustState = true
1220
+ textStorage.beginEditing()
1221
+ textStorage.setAttributedString(attrStr)
1222
+ textStorage.endEditing()
1223
+ lastAuthorizedText = textStorage.string
1224
+ isApplyingRustState = false
1225
+
1226
+ placeholderLabel.isHidden = !textStorage.string.isEmpty
1227
+ Self.updateLog.debug(
1228
+ "[applyUpdateJSON.rendered] after=\(self.textSnapshotSummary(), privacy: .public)"
1229
+ )
1230
+
1231
+ // Apply the selection from the update.
1232
+ if let selection = update["selection"] as? [String: Any] {
1233
+ applySelectionFromJSON(selection)
1234
+ }
1235
+ refreshTypingAttributesForSelection()
1236
+ if heightBehavior == .autoGrow {
1237
+ notifyHeightChangeIfNeeded(force: true)
1238
+ }
1239
+
1240
+ Self.updateLog.debug(
1241
+ "[applyUpdateJSON.end] finalSelection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
1242
+ )
1243
+
1244
+ // Notify the delegate.
1245
+ if notifyDelegate {
1246
+ editorDelegate?.editorTextView(self, didReceiveUpdate: updateJSON)
1247
+ }
1248
+ }
1249
+
1250
+ /// Apply a render JSON string (just render elements, no update wrapper).
1251
+ ///
1252
+ /// Used for initial content loading (set_html / set_json return render
1253
+ /// elements directly, not wrapped in an EditorUpdate).
1254
+ func applyRenderJSON(_ renderJSON: String) {
1255
+ Self.updateLog.debug(
1256
+ "[applyRenderJSON.begin] before=\(self.textSnapshotSummary(), privacy: .public)"
1257
+ )
1258
+ let attrStr = RenderBridge.renderElements(
1259
+ fromJSON: renderJSON,
1260
+ baseFont: baseFont,
1261
+ textColor: baseTextColor,
1262
+ theme: theme
1263
+ )
1264
+
1265
+ isApplyingRustState = true
1266
+ textStorage.beginEditing()
1267
+ textStorage.setAttributedString(attrStr)
1268
+ textStorage.endEditing()
1269
+ lastAuthorizedText = textStorage.string
1270
+ isApplyingRustState = false
1271
+
1272
+ placeholderLabel.isHidden = !textStorage.string.isEmpty
1273
+ refreshTypingAttributesForSelection()
1274
+ if heightBehavior == .autoGrow {
1275
+ notifyHeightChangeIfNeeded(force: true)
1276
+ }
1277
+ Self.updateLog.debug(
1278
+ "[applyRenderJSON.end] after=\(self.textSnapshotSummary(), privacy: .public)"
1279
+ )
1280
+ }
1281
+
1282
+ /// Apply a selection from a parsed JSON selection object.
1283
+ ///
1284
+ /// The selection JSON matches the format from `serialize_editor_update`:
1285
+ /// ```json
1286
+ /// {"type": "text", "anchor": 5, "head": 5}
1287
+ /// {"type": "node", "pos": 10}
1288
+ /// {"type": "all"}
1289
+ /// ```
1290
+ private func applySelectionFromJSON(_ selection: [String: Any]) {
1291
+ guard let type = selection["type"] as? String else { return }
1292
+
1293
+ isApplyingRustState = true
1294
+ defer { isApplyingRustState = false }
1295
+
1296
+ switch type {
1297
+ case "text":
1298
+ guard let anchorNum = selection["anchor"] as? NSNumber,
1299
+ let headNum = selection["head"] as? NSNumber
1300
+ else { return }
1301
+ // anchor/head from Rust are document positions; convert to scalar offsets first.
1302
+ let anchorScalar = editorDocToScalar(id: editorId, docPos: anchorNum.uint32Value)
1303
+ let headScalar = editorDocToScalar(id: editorId, docPos: headNum.uint32Value)
1304
+
1305
+ let startPos = PositionBridge.scalarToTextView(min(anchorScalar, headScalar), in: self)
1306
+ let endPos = PositionBridge.scalarToTextView(max(anchorScalar, headScalar), in: self)
1307
+ selectedTextRange = textRange(from: startPos, to: endPos)
1308
+ Self.selectionLog.debug(
1309
+ "[applySelectionFromJSON.text] doc=\(anchorNum.uint32Value)-\(headNum.uint32Value) scalar=\(anchorScalar)-\(headScalar) final=\(self.selectionSummary(), privacy: .public)"
1310
+ )
1311
+
1312
+ case "node":
1313
+ // Node selection: select the object replacement character at that position.
1314
+ guard let posNum = selection["pos"] as? NSNumber else { return }
1315
+ // pos from Rust is a document position; convert to scalar offset.
1316
+ let posScalar = editorDocToScalar(id: editorId, docPos: posNum.uint32Value)
1317
+ let startPos = PositionBridge.scalarToTextView(posScalar, in: self)
1318
+ // Select one character (the void node placeholder).
1319
+ if let endPos = position(from: startPos, offset: 1) {
1320
+ selectedTextRange = textRange(from: startPos, to: endPos)
1321
+ }
1322
+ Self.selectionLog.debug(
1323
+ "[applySelectionFromJSON.node] doc=\(posNum.uint32Value) scalar=\(posScalar) final=\(self.selectionSummary(), privacy: .public)"
1324
+ )
1325
+
1326
+ case "all":
1327
+ selectedTextRange = textRange(from: beginningOfDocument, to: endOfDocument)
1328
+ Self.selectionLog.debug(
1329
+ "[applySelectionFromJSON.all] final=\(self.selectionSummary(), privacy: .public)"
1330
+ )
1331
+
1332
+ default:
1333
+ break
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ // MARK: - EditorTextView + NSTextStorageDelegate (Reconciliation Fallback)
1339
+
1340
+ extension EditorTextView: NSTextStorageDelegate {
1341
+
1342
+ /// Detect unauthorized text storage mutations after UIKit finishes
1343
+ /// processing an editing operation. If the text storage diverges from
1344
+ /// the last Rust-authorized content and the change was NOT initiated by
1345
+ /// our Rust apply path, re-render from Rust ("Rust wins").
1346
+ func textStorage(
1347
+ _ textStorage: NSTextStorage,
1348
+ didProcessEditing editedMask: NSTextStorage.EditActions,
1349
+ range editedRange: NSRange,
1350
+ changeInLength delta: Int
1351
+ ) {
1352
+ // Only care about actual character edits, not attribute-only changes.
1353
+ guard editedMask.contains(.editedCharacters) else { return }
1354
+
1355
+ // Skip if this change came from our own Rust apply path.
1356
+ guard !isApplyingRustState, !isInterceptingInput else { return }
1357
+
1358
+ // Skip if no editor is bound yet (nothing to reconcile against).
1359
+ guard editorId != 0 else { return }
1360
+
1361
+ // Compare current text storage content against last authorized snapshot.
1362
+ let currentText = textStorage.string
1363
+ guard currentText != lastAuthorizedText else { return }
1364
+ let authorizedPreview = preview(lastAuthorizedText)
1365
+ let storagePreview = preview(currentText)
1366
+
1367
+ // --- Divergence detected ---
1368
+ reconciliationCount += 1
1369
+
1370
+ Self.reconciliationLog.warning(
1371
+ """
1372
+ [NativeEditor:reconciliation] Text storage diverged from Rust state \
1373
+ (count: \(self.reconciliationCount), \
1374
+ delta: \(delta), \
1375
+ editedRange: \(editedRange.location)..<\(editedRange.location + editedRange.length), \
1376
+ authorizedLen: \(self.lastAuthorizedText.count), \
1377
+ storageLen: \(currentText.count), \
1378
+ selection: \(self.selectionSummary(), privacy: .public), \
1379
+ interceptedDepth: \(self.interceptedInputDepth), \
1380
+ composing: \(self.isComposing), \
1381
+ authorizedPreview: \(authorizedPreview, privacy: .public), \
1382
+ storagePreview: \(storagePreview, privacy: .public))
1383
+ """
1384
+ )
1385
+
1386
+ // Reconcile by pulling the current editor state without rebuilding
1387
+ // the Rust backend or clearing history.
1388
+ let stateJSON = editorGetCurrentState(id: editorId)
1389
+ applyUpdateJSON(stateJSON)
1390
+ }
1391
+ }
1392
+
1393
+ // MARK: - RichTextEditorView (Fabric Host)
1394
+
1395
+ /// The top-level container view that a Fabric component would own.
1396
+ ///
1397
+ /// Hosts the EditorTextView. In a full Fabric integration, this would be
1398
+ /// a `RCTViewComponentView` subclass registered via the component descriptor.
1399
+ ///
1400
+ /// For now, this is a plain UIView that can be used in a UIKit context
1401
+ /// and serves as the integration point for the future Fabric component.
1402
+ final class RichTextEditorView: UIView {
1403
+
1404
+ // MARK: - Properties
1405
+
1406
+ /// The editor text view that handles input interception.
1407
+ let textView: EditorTextView
1408
+ var onHeightMayChange: (() -> Void)?
1409
+ private var lastAutoGrowWidth: CGFloat = 0
1410
+
1411
+ var heightBehavior: EditorHeightBehavior = .fixed {
1412
+ didSet {
1413
+ guard oldValue != heightBehavior else { return }
1414
+ textView.heightBehavior = heightBehavior
1415
+ invalidateIntrinsicContentSize()
1416
+ setNeedsLayout()
1417
+ onHeightMayChange?()
1418
+ }
1419
+ }
1420
+
1421
+ /// The Rust editor instance ID. Setting this binds/unbinds the editor.
1422
+ var editorId: UInt64 = 0 {
1423
+ didSet {
1424
+ if editorId != 0 {
1425
+ textView.bindEditor(id: editorId)
1426
+ } else {
1427
+ textView.unbindEditor()
1428
+ }
1429
+ }
1430
+ }
1431
+
1432
+ // MARK: - Initialization
1433
+
1434
+ override init(frame: CGRect) {
1435
+ textView = EditorTextView(frame: .zero, textContainer: nil)
1436
+ super.init(frame: frame)
1437
+ setupView()
1438
+ }
1439
+
1440
+ required init?(coder: NSCoder) {
1441
+ textView = EditorTextView(frame: .zero, textContainer: nil)
1442
+ super.init(coder: coder)
1443
+ setupView()
1444
+ }
1445
+
1446
+ private func setupView() {
1447
+ // Add the text view as a subview.
1448
+ textView.translatesAutoresizingMaskIntoConstraints = false
1449
+ textView.onHeightMayChange = { [weak self] in
1450
+ guard let self, self.heightBehavior == .autoGrow else { return }
1451
+ self.invalidateIntrinsicContentSize()
1452
+ self.superview?.setNeedsLayout()
1453
+ self.onHeightMayChange?()
1454
+ }
1455
+ addSubview(textView)
1456
+
1457
+ NSLayoutConstraint.activate([
1458
+ textView.topAnchor.constraint(equalTo: topAnchor),
1459
+ textView.leadingAnchor.constraint(equalTo: leadingAnchor),
1460
+ textView.trailingAnchor.constraint(equalTo: trailingAnchor),
1461
+ textView.bottomAnchor.constraint(equalTo: bottomAnchor),
1462
+ ])
1463
+ }
1464
+
1465
+ override var intrinsicContentSize: CGSize {
1466
+ guard heightBehavior == .autoGrow else {
1467
+ return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
1468
+ }
1469
+
1470
+ let measuredHeight = measuredEditorHeight()
1471
+ guard measuredHeight > 0 else {
1472
+ return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
1473
+ }
1474
+ return CGSize(width: UIView.noIntrinsicMetric, height: measuredHeight)
1475
+ }
1476
+
1477
+ override func layoutSubviews() {
1478
+ super.layoutSubviews()
1479
+ guard heightBehavior == .autoGrow else { return }
1480
+ let currentWidth = bounds.width.rounded(.towardZero)
1481
+ guard currentWidth != lastAutoGrowWidth else { return }
1482
+ lastAutoGrowWidth = currentWidth
1483
+ invalidateIntrinsicContentSize()
1484
+ }
1485
+
1486
+ // MARK: - Configuration
1487
+
1488
+ /// Configure the editor's appearance.
1489
+ ///
1490
+ /// - Parameters:
1491
+ /// - font: Base font for unstyled text.
1492
+ /// - textColor: Default text color.
1493
+ /// - backgroundColor: Background color for the text view.
1494
+ func configure(
1495
+ font: UIFont = .systemFont(ofSize: 16),
1496
+ textColor: UIColor = .label,
1497
+ backgroundColor: UIColor = .systemBackground
1498
+ ) {
1499
+ textView.baseFont = font
1500
+ textView.baseTextColor = textColor
1501
+ textView.baseBackgroundColor = backgroundColor
1502
+ textView.font = font
1503
+ textView.textColor = textColor
1504
+ textView.backgroundColor = backgroundColor
1505
+ }
1506
+
1507
+ func applyTheme(_ theme: EditorTheme?) {
1508
+ textView.applyTheme(theme)
1509
+ let cornerRadius = theme?.borderRadius ?? 0
1510
+ layer.cornerRadius = cornerRadius
1511
+ clipsToBounds = cornerRadius > 0
1512
+ }
1513
+
1514
+ /// Set initial content from HTML.
1515
+ ///
1516
+ /// - Parameter html: The HTML string to load.
1517
+ func setContent(html: String) {
1518
+ guard editorId != 0 else { return }
1519
+ let renderJSON = editorSetHtml(id: editorId, html: html)
1520
+ textView.applyRenderJSON(renderJSON)
1521
+ }
1522
+
1523
+ /// Set initial content from ProseMirror JSON.
1524
+ ///
1525
+ /// - Parameter json: The JSON string to load.
1526
+ func setContent(json: String) {
1527
+ guard editorId != 0 else { return }
1528
+ let renderJSON = editorSetJson(id: editorId, json: json)
1529
+ textView.applyRenderJSON(renderJSON)
1530
+ }
1531
+
1532
+ private func measuredEditorHeight() -> CGFloat {
1533
+ let width = resolvedMeasurementWidth()
1534
+ guard width > 0 else { return 0 }
1535
+ return ceil(
1536
+ textView.sizeThatFits(
1537
+ CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)
1538
+ ).height
1539
+ )
1540
+ }
1541
+
1542
+ private func resolvedMeasurementWidth() -> CGFloat {
1543
+ if bounds.width > 0 {
1544
+ return bounds.width
1545
+ }
1546
+ if superview?.bounds.width ?? 0 > 0 {
1547
+ return superview?.bounds.width ?? 0
1548
+ }
1549
+ return UIScreen.main.bounds.width
1550
+ }
1551
+
1552
+ // MARK: - Cleanup
1553
+
1554
+ deinit {
1555
+ if editorId != 0 {
1556
+ textView.unbindEditor()
1557
+ }
1558
+ }
1559
+ }