@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.
- package/LICENSE +160 -0
- package/README.md +143 -0
- package/android/build.gradle +39 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
- package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
- package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
- package/expo-module.config.json +9 -0
- package/ios/EditorAddons.swift +228 -0
- package/ios/EditorCore.xcframework/Info.plist +44 -0
- 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/EditorLayoutManager.swift +254 -0
- package/ios/EditorTheme.swift +372 -0
- package/ios/Generated_editor_core.swift +1143 -0
- package/ios/NativeEditorExpoView.swift +1417 -0
- package/ios/NativeEditorModule.swift +263 -0
- package/ios/PositionBridge.swift +278 -0
- package/ios/ReactNativeProseEditor.podspec +49 -0
- package/ios/RenderBridge.swift +825 -0
- package/ios/RichTextEditorView.swift +1559 -0
- package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
- package/ios/editor_coreFFI/module.modulemap +7 -0
- package/ios/editor_coreFFI.h +904 -0
- package/ios/editor_coreFFI.modulemap +7 -0
- package/package.json +66 -0
- 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 +2014 -0
- package/src/EditorTheme.ts +130 -0
- package/src/EditorToolbar.tsx +620 -0
- package/src/NativeEditorBridge.ts +607 -0
- package/src/NativeRichTextEditor.tsx +951 -0
- package/src/addons.ts +158 -0
- package/src/index.ts +63 -0
- package/src/schemas.ts +153 -0
- 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
|
+
}
|