@apollohg/react-native-rich-text-editor 2.0.1 → 2.0.3

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 (63) hide show
  1. package/README.md +29 -123
  2. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +20 -6
  3. package/android/src/main/java/com/apollohg/editor/EditorEditTextClipboard.kt +181 -36
  4. package/android/src/main/java/com/apollohg/editor/EditorEditTextImages.kt +25 -0
  5. package/android/src/main/java/com/apollohg/editor/EditorEditTextSelection.kt +7 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorTextSurfaceInteraction.kt +31 -11
  7. package/android/src/main/java/com/apollohg/editor/EditorV2Adapter.kt +36 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorV2Backend.kt +1 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorV2Driver.kt +10 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorV2RenderValidation.kt +1 -0
  11. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +101 -33
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +2 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoViewProperties.kt +4 -0
  14. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderMediaSpans.kt +15 -3
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +14 -0
  17. package/android/src/main/java/com/apollohg/editor/UniffiEditorV2Backend.kt +4 -0
  18. package/android/src/main/java/com/apollohg/editor/viewer/AndroidProseLayoutEngine.kt +1 -1
  19. package/dist/EditorStyleSheetNormalization.js +1 -1
  20. package/dist/EditorStyleSheetTypes.d.ts +1 -1
  21. package/dist/NativeRichTextEditor.d.ts +1 -1
  22. package/dist/RichTextEditorNativeTypes.d.ts +2 -1
  23. package/dist/RichTextEditorTypes.d.ts +4 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/useRichTextEditorPresentation.d.ts +1 -1
  26. package/dist/useRichTextEditorPresentation.js +2 -2
  27. package/dist/useRichTextEditorState.d.ts +5 -3
  28. package/dist/useRichTextEditorState.js +4 -3
  29. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  30. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  31. package/ios/EditorInteractionOverlays.swift +114 -122
  32. package/ios/EditorLayoutManager.swift +13 -3
  33. package/ios/EditorStyleSheet.swift +12 -1
  34. package/ios/EditorTextView+Images.swift +32 -10
  35. package/ios/EditorTextView+KeyboardInput.swift +299 -0
  36. package/ios/EditorTextView.swift +64 -44
  37. package/ios/EditorV2Adapter+Commands.swift +22 -0
  38. package/ios/EditorV2Adapter+RenderValidation.swift +2 -2
  39. package/ios/EditorV2Shadow.swift +12 -0
  40. package/ios/Generated_editor_core.swift +10 -0
  41. package/ios/NativeEditorExpoView+Properties.swift +4 -0
  42. package/ios/NativeEditorModule.swift +3 -0
  43. package/ios/NativeToolbar+ButtonStyle.swift +17 -5
  44. package/ios/NativeToolbar+Diagnostics.swift +15 -0
  45. package/ios/RenderBridge+Atoms.swift +0 -1
  46. package/ios/RenderBridge+Attachments.swift +0 -29
  47. package/ios/RichTextEditorView.swift +4 -12
  48. package/ios/Viewer/CoreTextProseLayoutEngine+AttributedText.swift +3 -2
  49. package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
  50. package/package.json +1 -1
  51. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  52. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  53. package/rust/android/x86/libeditor_core.so +0 -0
  54. package/rust/android/x86_64/libeditor_core.so +0 -0
  55. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +18 -0
  56. package/src/EditorStyleSheetNormalization.ts +1 -1
  57. package/src/EditorStyleSheetTypes.ts +1 -1
  58. package/src/NativeRichTextEditor.tsx +1 -0
  59. package/src/RichTextEditorNativeTypes.tsx +2 -0
  60. package/src/RichTextEditorTypes.tsx +5 -0
  61. package/src/index.ts +1 -0
  62. package/src/useRichTextEditorPresentation.tsx +3 -0
  63. package/src/useRichTextEditorState.tsx +5 -2
@@ -1,7 +1,306 @@
1
1
  import os
2
2
  import UIKit
3
3
 
4
+ enum EditorPasteMode: String {
5
+ case rich
6
+ case plainText
7
+ case disabled
8
+ }
9
+
10
+ struct EditorClipboardPayload {
11
+ static let fragmentType = "com.apollohg.native-editor.fragment"
12
+
13
+ let fragment: String
14
+ let html: String
15
+ let text: String
16
+
17
+ init(fragment: String, html: String, text: String) {
18
+ self.fragment = fragment
19
+ self.html = html
20
+ self.text = text
21
+ }
22
+
23
+ init?(json: String) {
24
+ guard let data = json.data(using: .utf8),
25
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
26
+ object["empty"] as? Bool != true,
27
+ let fragment = object["fragment"] as? String,
28
+ let html = object["html"] as? String,
29
+ let text = object["text"] as? String
30
+ else { return nil }
31
+ self.init(fragment: fragment, html: html, text: text)
32
+ }
33
+
34
+ @discardableResult
35
+ func write(to pasteboard: UIPasteboard) -> Bool {
36
+ pasteboard.items = [[
37
+ Self.fragmentType: Data(fragment.utf8),
38
+ "public.html": Data(html.utf8),
39
+ "public.utf8-plain-text": text
40
+ ]]
41
+ return pasteboard.data(forPasteboardType: Self.fragmentType) != nil
42
+ && pasteboard.data(forPasteboardType: "public.html") != nil
43
+ && pasteboard.string != nil
44
+ }
45
+ }
46
+
47
+ enum EditorClipboardPaste {
48
+ static let maximumRTFBytes = 64 * 1_024 * 1_024
49
+
50
+ static let supportedTypes = [
51
+ EditorClipboardPayload.fragmentType,
52
+ "public.html",
53
+ "public.rtf",
54
+ "public.utf8-plain-text",
55
+ "public.plain-text",
56
+ "public.text"
57
+ ]
58
+
59
+ static func hasSupportedContent(in pasteboard: UIPasteboard) -> Bool {
60
+ pasteboard.contains(pasteboardTypes: supportedTypes)
61
+ }
62
+
63
+ static func command(
64
+ from pasteboard: UIPasteboard,
65
+ mode: EditorPasteMode,
66
+ maximumRTFBytes: Int = EditorClipboardPaste.maximumRTFBytes
67
+ ) -> [String: Any]? {
68
+ guard mode != .disabled else { return nil }
69
+ var command: [String: Any] = ["type": "paste"]
70
+ if let fragment = utf8String(pasteboard.data(forPasteboardType: EditorClipboardPayload.fragmentType)) {
71
+ command["fragment"] = fragment
72
+ }
73
+ var html = utf8String(pasteboard.data(forPasteboardType: "public.html"))
74
+ var text = pasteboard.string
75
+ if html == nil,
76
+ let rtf = pasteboard.data(forPasteboardType: "public.rtf"),
77
+ rtf.count <= maximumRTFBytes,
78
+ let attributed = try? NSAttributedString(
79
+ data: rtf,
80
+ options: [.documentType: NSAttributedString.DocumentType.rtf],
81
+ documentAttributes: nil
82
+ ) {
83
+ let convertedHTML = semanticHTML(from: attributed)
84
+ if !convertedHTML.isEmpty {
85
+ html = convertedHTML
86
+ }
87
+ if text == nil {
88
+ text = attributed.string
89
+ }
90
+ }
91
+ let hasStructuredContent = command["fragment"] != nil || html != nil
92
+ guard hasStructuredContent || !(text?.isEmpty ?? true) else { return nil }
93
+ if let html {
94
+ command["html"] = html
95
+ }
96
+ if let text {
97
+ command["text"] = text
98
+ }
99
+ if mode == .plainText {
100
+ command["plainText"] = true
101
+ }
102
+ return command.count > 1 ? command : nil
103
+ }
104
+
105
+ private static func utf8String(_ data: Data?) -> String? {
106
+ guard let data else { return nil }
107
+ return String(data: data, encoding: .utf8)
108
+ }
109
+
110
+ private static func semanticHTML(from attributed: NSAttributedString) -> String {
111
+ let range = NSRange(location: 0, length: attributed.length)
112
+ var hasList = false
113
+ attributed.enumerateAttribute(.paragraphStyle, in: range) { value, _, stop in
114
+ if let style = value as? NSParagraphStyle, !style.textLists.isEmpty {
115
+ hasList = true
116
+ stop.pointee = true
117
+ }
118
+ }
119
+ guard attributed.string.contains("\n") || attributed.string.contains("\r") || hasList else {
120
+ return semanticInlineHTML(from: attributed, range: range)
121
+ }
122
+
123
+ let string = attributed.string as NSString
124
+ var paragraphs: [(html: String, listTags: [String])] = []
125
+ var location = 0
126
+ while location < string.length {
127
+ let paragraphRange = string.paragraphRange(
128
+ for: NSRange(location: location, length: 0)
129
+ )
130
+ var contentLength = paragraphRange.length
131
+ while contentLength > 0 {
132
+ let scalar = string.character(at: paragraphRange.location + contentLength - 1)
133
+ guard scalar == 0x000A || scalar == 0x000D else { break }
134
+ contentLength -= 1
135
+ }
136
+ let contentRange = NSRange(location: paragraphRange.location, length: contentLength)
137
+ let styleLocation = min(paragraphRange.location, max(attributed.length - 1, 0))
138
+ let style = attributed.length > 0
139
+ ? attributed.attribute(.paragraphStyle, at: styleLocation, effectiveRange: nil)
140
+ as? NSParagraphStyle
141
+ : nil
142
+ paragraphs.append((
143
+ html: semanticInlineHTML(from: attributed, range: contentRange),
144
+ listTags: style?.textLists.map(listTag) ?? []
145
+ ))
146
+ location = NSMaxRange(paragraphRange)
147
+ }
148
+ if let finalScalar = attributed.string.utf16.last,
149
+ finalScalar == 0x000A || finalScalar == 0x000D {
150
+ let style = attributed.attribute(
151
+ .paragraphStyle,
152
+ at: attributed.length - 1,
153
+ effectiveRange: nil
154
+ ) as? NSParagraphStyle
155
+ paragraphs.append((html: "", listTags: style?.textLists.map(listTag) ?? []))
156
+ }
157
+
158
+ var html = ""
159
+ var openListTags: [String] = []
160
+ for paragraph in paragraphs {
161
+ guard !paragraph.listTags.isEmpty else {
162
+ closeLists(&openListTags, into: &html)
163
+ html += "<p>\(paragraph.html)</p>"
164
+ continue
165
+ }
166
+
167
+ let sharedDepth = zip(openListTags, paragraph.listTags)
168
+ .prefix { $0.0 == $0.1 }
169
+ .count
170
+ while openListTags.count > sharedDepth {
171
+ let tag = openListTags.removeLast()
172
+ html += "</li></\(tag)>"
173
+ }
174
+ if !openListTags.isEmpty, openListTags.count == paragraph.listTags.count {
175
+ html += "</li><li>"
176
+ }
177
+ for tag in paragraph.listTags.dropFirst(sharedDepth) {
178
+ html += "<\(tag)><li>"
179
+ openListTags.append(tag)
180
+ }
181
+ html += paragraph.html
182
+ }
183
+ closeLists(&openListTags, into: &html)
184
+ return html
185
+ }
186
+
187
+ private static func semanticInlineHTML(
188
+ from attributed: NSAttributedString,
189
+ range: NSRange
190
+ ) -> String {
191
+ var html = ""
192
+ attributed.enumerateAttributes(in: range) { attributes, range, _ in
193
+ var run = escapeHTMLText(attributed.attributedSubstring(from: range).string)
194
+ if let font = attributes[.font] as? UIFont {
195
+ let traits = font.fontDescriptor.symbolicTraits
196
+ if traits.contains(.traitBold) {
197
+ run = "<strong>\(run)</strong>"
198
+ }
199
+ if traits.contains(.traitItalic) {
200
+ run = "<em>\(run)</em>"
201
+ }
202
+ }
203
+ if styleIsSet(attributes[.underlineStyle]) {
204
+ run = "<u>\(run)</u>"
205
+ }
206
+ if styleIsSet(attributes[.strikethroughStyle]) {
207
+ run = "<s>\(run)</s>"
208
+ }
209
+ if let href = linkString(attributes[.link]) {
210
+ run = "<a href=\"\(escapeHTMLAttribute(href))\">\(run)</a>"
211
+ }
212
+ html += run
213
+ }
214
+ guard !html.isEmpty else { return "" }
215
+ return "<span style=\"white-space:pre-wrap\">\(html)</span>"
216
+ }
217
+
218
+ private static func listTag(_ list: NSTextList) -> String {
219
+ let marker = String(describing: list.markerFormat).lowercased()
220
+ let orderedMarkers = ["decimal", "roman", "alpha", "latin"]
221
+ return orderedMarkers.contains { marker.contains($0) } ? "ol" : "ul"
222
+ }
223
+
224
+ private static func closeLists(_ tags: inout [String], into html: inout String) {
225
+ while let tag = tags.popLast() {
226
+ html += "</li></\(tag)>"
227
+ }
228
+ }
229
+
230
+ private static func escapeHTMLText(_ value: String) -> String {
231
+ value
232
+ .replacingOccurrences(of: "&", with: "&amp;")
233
+ .replacingOccurrences(of: "<", with: "&lt;")
234
+ .replacingOccurrences(of: ">", with: "&gt;")
235
+ .replacingOccurrences(of: "\r\n", with: "\n")
236
+ .replacingOccurrences(of: "\r", with: "\n")
237
+ .replacingOccurrences(of: "\n", with: "<br>")
238
+ }
239
+
240
+ private static func escapeHTMLAttribute(_ value: String) -> String {
241
+ value
242
+ .replacingOccurrences(of: "&", with: "&amp;")
243
+ .replacingOccurrences(of: "<", with: "&lt;")
244
+ .replacingOccurrences(of: ">", with: "&gt;")
245
+ .replacingOccurrences(of: "\"", with: "&quot;")
246
+ .replacingOccurrences(of: "'", with: "&#39;")
247
+ }
248
+
249
+ private static func styleIsSet(_ value: Any?) -> Bool {
250
+ guard let number = value as? NSNumber else { return false }
251
+ return number.intValue != 0
252
+ }
253
+
254
+ private static func linkString(_ value: Any?) -> String? {
255
+ if let url = value as? URL {
256
+ return url.absoluteString
257
+ }
258
+ return value as? String
259
+ }
260
+ }
261
+
4
262
  extension EditorTextView {
263
+ @discardableResult
264
+ func exportSelectionToPasteboard(_ pasteboard: UIPasteboard = .general) -> Bool {
265
+ ensureInternalTextViewDelegate()
266
+ guard editorId != 0 else { return false }
267
+ guard finishExternalTextCompositionBeforeInteractionIfNeeded() else { return false }
268
+ guard prepareForExternalEditorUpdate() else { return false }
269
+ guard syncClipboardSelectionToRust(requiresRange: true) != nil else { return false }
270
+ guard let payload = EditorV2Shadow.clipboardPayload(id: editorId) else { return false }
271
+ return payload.write(to: pasteboard)
272
+ }
273
+
274
+ func syncClipboardSelectionToRust(
275
+ requiresRange: Bool = false
276
+ ) -> (anchor: UInt32, head: UInt32)? {
277
+ guard let selection = currentScalarSelection() else { return nil }
278
+ guard !requiresRange
279
+ || selection.anchor != selection.head
280
+ || selectedImageSelectionState() != nil
281
+ else { return nil }
282
+ let sync: EditorV2SelectionSync?
283
+ if let image = selectedImageSelectionState() {
284
+ sync = EditorV2Shadow.setNodeSelection(id: editorId, docPos: image.docPos)
285
+ } else {
286
+ sync = EditorV2Shadow.setSelectionScalar(
287
+ id: editorId,
288
+ scalarAnchor: selection.anchor,
289
+ scalarHead: selection.head
290
+ )
291
+ }
292
+ guard let sync else { return nil }
293
+ if let refreshed = sync.refreshedUpdateJSON {
294
+ applyUpdateJSON(refreshed, notifyDelegate: false)
295
+ }
296
+ return selection
297
+ }
298
+
299
+ func applyClipboardCommand(_ command: [String: Any]) {
300
+ let updateJSON = EditorV2Shadow.applyClipboardCommand(id: editorId, command: command)
301
+ applyUpdateJSON(updateJSON)
302
+ }
303
+
5
304
  @objc func handleIndentKeyCommand() {
6
305
  handleListDepthKeyCommand(outdent: false)
7
306
  }
@@ -35,6 +35,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate, UITextDragD
35
35
  var hidesNativeSelectionChrome = false
36
36
  var isPreviewingImageResize = false
37
37
  var allowImageResizing = true
38
+ var pasteMode: EditorPasteMode = .rich
38
39
 
39
40
  override var isEditable: Bool {
40
41
  didSet {
@@ -415,7 +416,7 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate, UITextDragD
415
416
  height: min(maxPlaceholderHeight, ceil(fittedHeight))
416
417
  )
417
418
  }
418
- if heightBehavior == .autoGrow, !isPreviewingImageResize {
419
+ if heightBehavior == .autoGrow {
419
420
  let currentWidth = ceil(bounds.width)
420
421
  if abs(currentWidth - lastAutoGrowMeasuredWidth) > 0.5 {
421
422
  autoGrowHeightCheckIsDirty = true
@@ -622,6 +623,11 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate, UITextDragD
622
623
  input: "\t",
623
624
  modifierFlags: [.shift],
624
625
  action: #selector(handleOutdentKeyCommand)
626
+ ),
627
+ UIKeyCommand(
628
+ input: "v",
629
+ modifierFlags: [.command, .shift],
630
+ action: #selector(pasteAndMatchStyle(_:))
625
631
  )
626
632
  ]
627
633
  }
@@ -875,61 +881,75 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate, UITextDragD
875
881
  }
876
882
  }
877
883
 
878
- // MARK: - Paste Handling
884
+ // MARK: - Clipboard
885
+
886
+ override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
887
+ guard editorId != 0 else {
888
+ return super.canPerformAction(action, withSender: sender)
889
+ }
890
+ switch action {
891
+ case #selector(copy(_:)):
892
+ return selectedTextRange?.isEmpty == false
893
+ case #selector(cut(_:)):
894
+ return isEditable && selectedTextRange?.isEmpty == false
895
+ case #selector(paste(_:)), #selector(pasteAndMatchStyle(_:)):
896
+ return isEditable
897
+ && pasteMode != .disabled
898
+ && EditorClipboardPaste.hasSupportedContent(in: .general)
899
+ default:
900
+ return super.canPerformAction(action, withSender: sender)
901
+ }
902
+ }
903
+
904
+ override func copy(_ sender: Any?) {
905
+ guard editorId != 0 else {
906
+ super.copy(sender)
907
+ return
908
+ }
909
+ _ = exportSelectionToPasteboard()
910
+ }
911
+
912
+ override func cut(_ sender: Any?) {
913
+ guard editorId != 0 else {
914
+ super.cut(sender)
915
+ return
916
+ }
917
+ guard isEditable, exportSelectionToPasteboard() else { return }
918
+ performInterceptedInput {
919
+ applyClipboardCommand(["type": "paste", "text": "", "plainText": true])
920
+ }
921
+ }
879
922
 
880
- /// Intercept paste operations to route content through Rust.
881
- ///
882
- /// Attempts to extract HTML from the pasteboard first (for rich text paste),
883
- /// falling back to plain text.
884
923
  override func paste(_ sender: Any?) {
924
+ pasteFromPasteboard(forcePlainText: false, sender: sender)
925
+ }
926
+
927
+ override func pasteAndMatchStyle(_ sender: Any?) {
928
+ pasteFromPasteboard(forcePlainText: true, sender: sender)
929
+ }
930
+
931
+ private func pasteFromPasteboard(forcePlainText: Bool, sender: Any?) {
885
932
  ensureInternalTextViewDelegate()
886
933
  guard editorId != 0 else {
887
- super.paste(sender)
934
+ if forcePlainText {
935
+ super.pasteAndMatchStyle(sender)
936
+ } else {
937
+ super.paste(sender)
938
+ }
888
939
  return
889
940
  }
941
+ guard isEditable, pasteMode != .disabled else { return }
890
942
  guard finishExternalTextCompositionBeforeInteractionIfNeeded() else { return }
891
943
  guard prepareForExternalEditorUpdate() else { return }
944
+ guard syncClipboardSelectionToRust() != nil else { return }
945
+ let mode: EditorPasteMode = forcePlainText ? .plainText : pasteMode
946
+ guard let command = EditorClipboardPaste.command(from: .general, mode: mode) else { return }
892
947
 
893
948
  Self.inputLog.debug(
894
949
  "[paste] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
895
950
  )
896
-
897
- let pasteboard = UIPasteboard.general
898
-
899
- if let htmlData = pasteboard.data(forPasteboardType: "public.html"),
900
- let html = String(data: htmlData, encoding: .utf8) {
901
- performInterceptedInput {
902
- pasteHTML(html)
903
- }
904
- return
905
- }
906
-
907
- if let rtfData = pasteboard.data(forPasteboardType: "public.rtf") {
908
- if let attrStr = try? NSAttributedString(
909
- data: rtfData,
910
- options: [.documentType: NSAttributedString.DocumentType.rtf],
911
- documentAttributes: nil
912
- ) {
913
- if let htmlData = try? attrStr.data(
914
- from: NSRange(location: 0, length: attrStr.length),
915
- documentAttributes: [.documentType: NSAttributedString.DocumentType.html]
916
- ), let html = String(data: htmlData, encoding: .utf8) {
917
- performInterceptedInput {
918
- if !pasteHTML(html, detectContentChange: true),
919
- !attrStr.string.isEmpty {
920
- pastePlainText(attrStr.string)
921
- }
922
- }
923
- return
924
- }
925
- }
926
- }
927
-
928
- if let text = pasteboard.string {
929
- performInterceptedInput {
930
- pastePlainText(text)
931
- }
932
- return
951
+ performInterceptedInput {
952
+ applyClipboardCommand(command)
933
953
  }
934
954
  }
935
955
 
@@ -1,6 +1,28 @@
1
1
  import Foundation
2
2
 
3
3
  extension EditorV2Adapter {
4
+ func clipboardPayloadJSON() -> String? {
5
+ guard beginRuntimeOperation() else { return nil }
6
+ defer { endRuntimeOperation() }
7
+ switch Self.normalizeJsonResult(editorV2GetClipboard(editorId: editorId)) {
8
+ case .failure(let error):
9
+ emit(error)
10
+ return nil
11
+ case .success(let json):
12
+ return json
13
+ }
14
+ }
15
+
16
+ func applyClipboardCommand(_ command: [String: Any]) -> String? {
17
+ guard beginRuntimeOperation() else { return nil }
18
+ defer { endRuntimeOperation() }
19
+ return performMutation(adoptEngineSelection: true) {
20
+ self.callWithEnvelope(["command": command]) { requestJson in
21
+ editorV2ApplyCommand(editorId: self.editorId, requestJson: requestJson)
22
+ }
23
+ }
24
+ }
25
+
4
26
  func toggleMark(_ markType: String, anchor: UInt32, head: UInt32) -> String? {
5
27
  guard beginRuntimeOperation() else { return nil }
6
28
  defer { endRuntimeOperation() }
@@ -150,7 +150,7 @@ extension EditorV2Adapter {
150
150
  private static func isValidMentionStyle(_ value: Any) -> Bool {
151
151
  guard let style = value as? [String: Any] else { return false }
152
152
  let colors: Set<String> = ["color", "backgroundColor", "textDecorationColor", "borderColor", "borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor"]
153
- let dimensions: Set<String> = ["fontSize", "lineHeight", "letterSpacing", "borderWidth", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "borderRadius", "borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius"]
153
+ let dimensions: Set<String> = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "fontSize", "lineHeight", "letterSpacing", "borderWidth", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "borderRadius", "borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius"]
154
154
  let strings: Set<String> = ["fontFamily", "fontWeight", "fontStyle", "textDecorationLine", "textDecorationStyle", "borderStyle"]
155
155
  guard hasOnlyKeys(style, colors.union(dimensions).union(strings)) else { return false }
156
156
  for key in colors where style[key] != nil {
@@ -159,7 +159,7 @@ extension EditorV2Adapter {
159
159
  for key in dimensions where style[key] != nil {
160
160
  guard let number = finiteNumber(style[key])?.doubleValue else { return false }
161
161
  if ["fontSize", "lineHeight"].contains(key), number <= 0 { return false }
162
- if key.hasPrefix("border"), number < 0 { return false }
162
+ if key.hasPrefix("border") || key.hasPrefix("padding"), number < 0 { return false }
163
163
  }
164
164
  for key in strings where style[key] != nil {
165
165
  guard let value = style[key] as? String else { return false }
@@ -71,6 +71,11 @@ enum EditorV2Shadow {
71
71
  adapter(for: id)?.contentSnapshotJSON() ?? "{\"html\":\"\",\"json\":{}}"
72
72
  }
73
73
 
74
+ static func clipboardPayload(id: UInt64) -> EditorClipboardPayload? {
75
+ guard let json = adapter(for: id)?.clipboardPayloadJSON() else { return nil }
76
+ return EditorClipboardPayload(json: json)
77
+ }
78
+
74
79
  // MARK: - State / selection reads
75
80
 
76
81
  static func getCurrentState(id: UInt64) -> String {
@@ -295,6 +300,13 @@ enum EditorV2Shadow {
295
300
  adapter(for: id)?.insertContentHtmlAtEngineSelection(html) ?? "{}"
296
301
  }
297
302
 
303
+ static func applyClipboardCommand(
304
+ id: UInt64,
305
+ command: [String: Any]
306
+ ) -> String {
307
+ adapter(for: id)?.applyClipboardCommand(command) ?? "{}"
308
+ }
309
+
298
310
  static func insertContentJson(id: UInt64, json: String) -> String {
299
311
  adapter(for: id)?.insertContentJsonAtEngineSelection(json) ?? "{}"
300
312
  }
@@ -2077,6 +2077,13 @@ public func editorV2DocToScalar(editorId: String, docPos: UInt32) -> FfiJsonResu
2077
2077
  )
2078
2078
  })
2079
2079
  }
2080
+ public func editorV2GetClipboard(editorId: String) -> FfiJsonResult {
2081
+ return try! FfiConverterTypeFfiJsonResult_lift(try! rustCall() {
2082
+ uniffi_editor_core_fn_func_editor_v2_get_clipboard(
2083
+ FfiConverterString.lower(editorId),$0
2084
+ )
2085
+ })
2086
+ }
2080
2087
  public func editorV2GetContentSnapshot(editorId: String) -> FfiJsonResult {
2081
2088
  return try! FfiConverterTypeFfiJsonResult_lift(try! rustCall() {
2082
2089
  uniffi_editor_core_fn_func_editor_v2_get_content_snapshot(
@@ -2289,6 +2296,9 @@ private let initializationResult: InitializationResult = {
2289
2296
  if (uniffi_editor_core_checksum_func_editor_v2_doc_to_scalar() != 62503) {
2290
2297
  return InitializationResult.apiChecksumMismatch
2291
2298
  }
2299
+ if (uniffi_editor_core_checksum_func_editor_v2_get_clipboard() != 3214) {
2300
+ return InitializationResult.apiChecksumMismatch
2301
+ }
2292
2302
  if (uniffi_editor_core_checksum_func_editor_v2_get_content_snapshot() != 21323) {
2293
2303
  return InitializationResult.apiChecksumMismatch
2294
2304
  }
@@ -104,6 +104,10 @@ extension NativeEditorExpoView {
104
104
  updateAccessoryToolbarVisibility()
105
105
  }
106
106
 
107
+ func setPasteMode(_ pasteMode: String) {
108
+ richTextView.textView.pasteMode = EditorPasteMode(rawValue: pasteMode) ?? .rich
109
+ }
110
+
107
111
  func setAccessibilityLabel(_ label: String?) {
108
112
  richTextView.textView.accessibilityLabel = label
109
113
  }
@@ -753,6 +753,9 @@ public class NativeEditorModule: BaseModule, @preconcurrency AnyModule {
753
753
  Prop("editable") { (view: NativeEditorExpoView, editable: Bool) in
754
754
  view.setEditable(editable)
755
755
  }
756
+ Prop("pasteMode") { (view: NativeEditorExpoView, pasteMode: String) in
757
+ view.setPasteMode(pasteMode)
758
+ }
756
759
  Prop("accessibilityLabel") { (view: NativeEditorExpoView, label: String?) in
757
760
  view.setAccessibilityLabel(label)
758
761
  }
@@ -108,9 +108,7 @@ extension EditorAccessoryToolbarView {
108
108
  ?? (resolvedAppearance == .native ? self.tintColor : .secondaryLabel)
109
109
  }
110
110
 
111
- button.tintColor = tintColor
112
- button.setTitleColor(tintColor, for: .normal)
113
- button.setTitleColor(tintColor, for: .disabled)
111
+ applyButtonForeground(to: button, color: tintColor)
114
112
  button.tintAdjustmentMode = enabled ? .automatic : .normal
115
113
  button.alpha = enabled || resolvedAppearance == .native ? 1 : 0.7
116
114
  let inactiveBackgroundColor = buttonStyle?.backgroundColor
@@ -139,7 +137,20 @@ extension EditorAccessoryToolbarView {
139
137
  color: backgroundColor,
140
138
  cornerRadius: cornerRadius
141
139
  )
142
- applyButtonIconStyle(to: button, item: item)
140
+ applyButtonIconStyle(to: button, item: item, color: tintColor)
141
+ }
142
+
143
+ /// A configured `UIButton` paints disabled content in its own system gray,
144
+ /// ignoring `tintColor`, so the colour must be forced through the
145
+ /// configuration's transformers as well.
146
+ private func applyButtonForeground(to button: UIButton, color: UIColor) {
147
+ button.tintColor = color
148
+ button.setTitleColor(color, for: .normal)
149
+ button.setTitleColor(color, for: .disabled)
150
+ guard #available(iOS 15.0, *), var configuration = button.configuration else { return }
151
+ configuration.baseForegroundColor = color
152
+ configuration.imageColorTransformer = UIConfigurationColorTransformer { _ in color }
153
+ button.configuration = configuration
143
154
  }
144
155
 
145
156
  /// Own the configured background to avoid stacking it with UIButton state fills.
@@ -175,13 +186,14 @@ extension EditorAccessoryToolbarView {
175
186
  return min(requestedSize, resolvedButtonSize)
176
187
  }
177
188
 
178
- private func applyButtonIconStyle(to button: UIButton, item: NativeToolbarItem) {
189
+ private func applyButtonIconStyle(to button: UIButton, item: NativeToolbarItem, color: UIColor) {
179
190
  let iconSize = resolvedButtonIconSize(for: item)
180
191
  let font = UIFont.systemFont(ofSize: iconSize, weight: .semibold)
181
192
  if #available(iOS 15.0, *), var configuration = button.configuration {
182
193
  configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
183
194
  var outgoing = incoming
184
195
  outgoing.font = font
196
+ outgoing.foregroundColor = color
185
197
  return outgoing
186
198
  }
187
199
  button.configuration = configuration
@@ -25,6 +25,21 @@ extension EditorAccessoryToolbarView {
25
25
  buttonBindings.indices.contains(index) ? buttonBindings[index].button.tintColor : nil
26
26
  }
27
27
 
28
+ /// The foreground colour a configured button will actually paint for its
29
+ /// current state, resolved through the configuration transformers rather
30
+ /// than `tintColor`, which UIKit ignores for disabled configured buttons.
31
+ func buttonPaintedForegroundColorForTesting(_ index: Int) -> UIColor? {
32
+ guard buttonBindings.indices.contains(index) else { return nil }
33
+ let button = buttonBindings[index].button
34
+ guard #available(iOS 15.0, *), let configuration = button.configuration else {
35
+ return button.titleColor(for: button.state)
36
+ }
37
+ if button.image(for: .normal) != nil {
38
+ return configuration.imageColorTransformer?(.black)
39
+ }
40
+ return configuration.titleTextAttributesTransformer?(AttributeContainer()).foregroundColor
41
+ }
42
+
28
43
  func buttonFontSizeForTesting(_ index: Int) -> CGFloat? {
29
44
  buttonBindings.indices.contains(index) ? buttonBindings[index].button.titleLabel?.font.pointSize : nil
30
45
  }
@@ -192,7 +192,6 @@ extension RenderBridge {
192
192
  }
193
193
  let attachment = BlockImageAttachment(
194
194
  source: source,
195
- placeholderTint: textColor,
196
195
  preferredWidth: jsonCGFloat(elementAttrs["width"]),
197
196
  preferredHeight: jsonCGFloat(elementAttrs["height"])
198
197
  )