@apollohg/react-native-prose-editor 0.2.0 → 0.3.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/android/src/main/java/com/apollohg/editor/EditorEditText.kt +228 -2
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +4 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +347 -10
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +76 -8
- package/dist/EditorToolbar.d.ts +9 -2
- package/dist/EditorToolbar.js +20 -10
- package/dist/NativeEditorBridge.d.ts +2 -0
- package/dist/NativeEditorBridge.js +3 -0
- package/dist/NativeRichTextEditor.d.ts +17 -1
- package/dist/NativeRichTextEditor.js +94 -37
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -1
- package/dist/schemas.d.ts +12 -0
- package/dist/schemas.js +45 -1
- package/ios/EditorCore.xcframework/Info.plist +5 -5
- 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 +0 -16
- package/ios/Generated_editor_core.swift +20 -2
- package/ios/NativeEditorExpoView.swift +51 -16
- package/ios/NativeEditorModule.swift +3 -0
- package/ios/RenderBridge.swift +208 -0
- package/ios/RichTextEditorView.swift +896 -15
- package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
- package/package.json +1 -1
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +25 -2
|
@@ -649,12 +649,14 @@ public func editorCoreVersion() -> String {
|
|
|
649
649
|
/**
|
|
650
650
|
* Create a new editor from a JSON config object.
|
|
651
651
|
*
|
|
652
|
-
* Config fields (all optional
|
|
652
|
+
* Config fields (all optional):
|
|
653
653
|
* - `"schema"`: custom schema definition (see `Schema::from_json`)
|
|
654
654
|
* - `"maxLength"`: maximum document length in characters
|
|
655
655
|
* - `"readOnly"`: if `true`, rejects non-API mutations
|
|
656
656
|
* - `"inputFilter"`: regex pattern; only matching characters are inserted
|
|
657
|
+
* - `"allowBase64Images"`: if `true`, parses `<img src="data:image/...">` as image nodes
|
|
657
658
|
*
|
|
659
|
+
* An empty object creates a default editor.
|
|
658
660
|
* Falls back to the default Tiptap schema when `"schema"` is absent or invalid.
|
|
659
661
|
*/
|
|
660
662
|
public func editorCreate(configJson: String) -> UInt64 {
|
|
@@ -945,6 +947,19 @@ public func editorReplaceTextScalar(id: UInt64, scalarFrom: UInt32, scalarTo: UI
|
|
|
945
947
|
)
|
|
946
948
|
})
|
|
947
949
|
}
|
|
950
|
+
/**
|
|
951
|
+
* Resize an image node at a document position. Returns an update JSON string.
|
|
952
|
+
*/
|
|
953
|
+
public func editorResizeImageAtDocPos(id: UInt64, docPos: UInt32, width: UInt32, height: UInt32) -> String {
|
|
954
|
+
return try! FfiConverterString.lift(try! rustCall() {
|
|
955
|
+
uniffi_editor_core_fn_func_editor_resize_image_at_doc_pos(
|
|
956
|
+
FfiConverterUInt64.lower(id),
|
|
957
|
+
FfiConverterUInt32.lower(docPos),
|
|
958
|
+
FfiConverterUInt32.lower(width),
|
|
959
|
+
FfiConverterUInt32.lower(height),$0
|
|
960
|
+
)
|
|
961
|
+
})
|
|
962
|
+
}
|
|
948
963
|
/**
|
|
949
964
|
* Convert a rendered-text scalar offset to a document position.
|
|
950
965
|
*/
|
|
@@ -1235,7 +1250,7 @@ private let initializationResult: InitializationResult = {
|
|
|
1235
1250
|
if (uniffi_editor_core_checksum_func_editor_core_version() != 41638) {
|
|
1236
1251
|
return InitializationResult.apiChecksumMismatch
|
|
1237
1252
|
}
|
|
1238
|
-
if (uniffi_editor_core_checksum_func_editor_create() !=
|
|
1253
|
+
if (uniffi_editor_core_checksum_func_editor_create() != 19812) {
|
|
1239
1254
|
return InitializationResult.apiChecksumMismatch
|
|
1240
1255
|
}
|
|
1241
1256
|
if (uniffi_editor_core_checksum_func_editor_delete_and_split_scalar() != 13764) {
|
|
@@ -1313,6 +1328,9 @@ private let initializationResult: InitializationResult = {
|
|
|
1313
1328
|
if (uniffi_editor_core_checksum_func_editor_replace_text_scalar() != 45475) {
|
|
1314
1329
|
return InitializationResult.apiChecksumMismatch
|
|
1315
1330
|
}
|
|
1331
|
+
if (uniffi_editor_core_checksum_func_editor_resize_image_at_doc_pos() != 36353) {
|
|
1332
|
+
return InitializationResult.apiChecksumMismatch
|
|
1333
|
+
}
|
|
1316
1334
|
if (uniffi_editor_core_checksum_func_editor_scalar_to_doc() != 40126) {
|
|
1317
1335
|
return InitializationResult.apiChecksumMismatch
|
|
1318
1336
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
import UIKit
|
|
3
|
-
import os
|
|
4
3
|
|
|
5
4
|
private struct NativeToolbarState {
|
|
6
5
|
let marks: [String: Bool]
|
|
@@ -92,6 +91,7 @@ private enum ToolbarDefaultIconId: String {
|
|
|
92
91
|
case underline
|
|
93
92
|
case strike
|
|
94
93
|
case link
|
|
94
|
+
case image
|
|
95
95
|
case blockquote
|
|
96
96
|
case bulletList
|
|
97
97
|
case orderedList
|
|
@@ -125,6 +125,7 @@ private struct NativeToolbarIcon {
|
|
|
125
125
|
.underline: "underline",
|
|
126
126
|
.strike: "strikethrough",
|
|
127
127
|
.link: "link",
|
|
128
|
+
.image: "photo",
|
|
128
129
|
.blockquote: "text.quote",
|
|
129
130
|
.bulletList: "list.bullet",
|
|
130
131
|
.orderedList: "list.number",
|
|
@@ -142,6 +143,7 @@ private struct NativeToolbarIcon {
|
|
|
142
143
|
.underline: "U",
|
|
143
144
|
.strike: "S",
|
|
144
145
|
.link: "🔗",
|
|
146
|
+
.image: "🖼",
|
|
145
147
|
.blockquote: "❝",
|
|
146
148
|
.bulletList: "•≡",
|
|
147
149
|
.orderedList: "1.",
|
|
@@ -484,9 +486,11 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
484
486
|
resolvedAppearance == .native
|
|
485
487
|
}
|
|
486
488
|
var usesUIGlassEffectForTesting: Bool {
|
|
489
|
+
#if compiler(>=6.2)
|
|
487
490
|
if #available(iOS 26.0, *) {
|
|
488
491
|
return blurView.effect is UIGlassEffect
|
|
489
492
|
}
|
|
493
|
+
#endif
|
|
490
494
|
return false
|
|
491
495
|
}
|
|
492
496
|
var chromeBorderWidthForTesting: CGFloat {
|
|
@@ -508,11 +512,13 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
508
512
|
activeNativeToolbarScrollViewForTesting.contentOffset.x = offsetX
|
|
509
513
|
}
|
|
510
514
|
var selectedButtonCountForTesting: Int {
|
|
515
|
+
#if compiler(>=6.2)
|
|
511
516
|
if #available(iOS 26.0, *) {
|
|
512
517
|
if usesNativeBarToolbar {
|
|
513
518
|
return barButtonBindings.filter { $0.button.style == .prominent }.count
|
|
514
519
|
}
|
|
515
520
|
}
|
|
521
|
+
#endif
|
|
516
522
|
return buttonBindings.filter(\.button.isSelected).count
|
|
517
523
|
}
|
|
518
524
|
func mentionButtonAtForTesting(_ index: Int) -> MentionSuggestionChipButton? {
|
|
@@ -602,6 +608,7 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
602
608
|
if #available(iOS 13.0, *) {
|
|
603
609
|
chromeView.layer.cornerCurve = .continuous
|
|
604
610
|
}
|
|
611
|
+
#if compiler(>=6.2)
|
|
605
612
|
if #available(iOS 26.0, *) {
|
|
606
613
|
let cornerConfig: UICornerConfiguration = usesNativeAppearance
|
|
607
614
|
? .capsule(maximumRadius: 24)
|
|
@@ -610,6 +617,7 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
610
617
|
blurView.cornerConfiguration = cornerConfig
|
|
611
618
|
glassTintView.cornerConfiguration = cornerConfig
|
|
612
619
|
}
|
|
620
|
+
#endif
|
|
613
621
|
chromeView.clipsToBounds = (usesNativeAppearance && !hasFloatingGlassButtons && !usesBarToolbar) || resolvedBorderRadius > 0
|
|
614
622
|
chromeView.layer.shadowOpacity = usesNativeAppearance && !hasFloatingGlassButtons && !usesBarToolbar ? 0.08 : 0
|
|
615
623
|
chromeView.layer.shadowRadius = usesNativeAppearance && !hasFloatingGlassButtons && !usesBarToolbar ? 10 : 0
|
|
@@ -682,6 +690,7 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
682
690
|
updateButtonAppearance(binding.button, item: binding.item, enabled: buttonState.enabled, active: buttonState.active)
|
|
683
691
|
}
|
|
684
692
|
|
|
693
|
+
#if compiler(>=6.2)
|
|
685
694
|
if #available(iOS 26.0, *), usesNativeBarToolbar {
|
|
686
695
|
for binding in barButtonBindings {
|
|
687
696
|
let state = buttonState(for: binding.item, state: currentState)
|
|
@@ -690,6 +699,17 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
690
699
|
binding.button.style = state.active ? .prominent : .plain
|
|
691
700
|
}
|
|
692
701
|
}
|
|
702
|
+
#endif
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
var firstButtonAlphaForTesting: CGFloat {
|
|
706
|
+
buttonBindings.first?.button.alpha ?? 0
|
|
707
|
+
}
|
|
708
|
+
var firstButtonTintColorForTesting: UIColor? {
|
|
709
|
+
buttonBindings.first?.button.tintColor
|
|
710
|
+
}
|
|
711
|
+
var firstButtonTintAdjustmentModeForTesting: UIView.TintAdjustmentMode {
|
|
712
|
+
buttonBindings.first?.button.tintAdjustmentMode ?? .automatic
|
|
693
713
|
}
|
|
694
714
|
|
|
695
715
|
func applyBoldStateForTesting(active: Bool, enabled: Bool) {
|
|
@@ -869,11 +889,15 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
869
889
|
stackView.addArrangedSubview(button)
|
|
870
890
|
}
|
|
871
891
|
|
|
892
|
+
#if compiler(>=6.2)
|
|
872
893
|
if #available(iOS 26.0, *) {
|
|
873
894
|
nativeToolbarView.setItems(makeNativeToolbarItems(from: compactItems), animated: false)
|
|
874
895
|
} else {
|
|
875
896
|
nativeToolbarView.setItems([], animated: false)
|
|
876
897
|
}
|
|
898
|
+
#else
|
|
899
|
+
nativeToolbarView.setItems([], animated: false)
|
|
900
|
+
#endif
|
|
877
901
|
|
|
878
902
|
updateNativeToolbarMetricsIfNeeded()
|
|
879
903
|
apply(theme: theme)
|
|
@@ -881,6 +905,7 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
881
905
|
}
|
|
882
906
|
|
|
883
907
|
private func updateNativeToolbarMetricsIfNeeded() {
|
|
908
|
+
#if compiler(>=6.2)
|
|
884
909
|
guard #available(iOS 26.0, *), usesNativeBarToolbar else {
|
|
885
910
|
nativeToolbarWidthConstraint?.constant = Self.baseHeight
|
|
886
911
|
nativeToolbarDidInitializeScrollPosition = false
|
|
@@ -932,8 +957,13 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
932
957
|
animated: false
|
|
933
958
|
)
|
|
934
959
|
}
|
|
960
|
+
#else
|
|
961
|
+
nativeToolbarWidthConstraint?.constant = Self.baseHeight
|
|
962
|
+
nativeToolbarDidInitializeScrollPosition = false
|
|
963
|
+
#endif
|
|
935
964
|
}
|
|
936
965
|
|
|
966
|
+
#if compiler(>=6.2)
|
|
937
967
|
@available(iOS 26.0, *)
|
|
938
968
|
private func makeNativeToolbarItems(from compactItems: [NativeToolbarItem]) -> [UIBarButtonItem] {
|
|
939
969
|
var toolbarItems: [UIBarButtonItem] = []
|
|
@@ -977,9 +1007,12 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
977
1007
|
barButtonItem.isEnabled = enabled
|
|
978
1008
|
barButtonItem.isSelected = active
|
|
979
1009
|
barButtonItem.style = active ? .prominent : .plain
|
|
1010
|
+
|
|
1011
|
+
barButtonItem.sharesBackground = true
|
|
980
1012
|
barButtonItem.hidesSharedBackground = active
|
|
981
1013
|
return barButtonItem
|
|
982
1014
|
}
|
|
1015
|
+
#endif
|
|
983
1016
|
|
|
984
1017
|
private func makeButton(item: NativeToolbarItem) -> UIButton {
|
|
985
1018
|
let button = UIButton(type: .system)
|
|
@@ -1106,6 +1139,7 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
1106
1139
|
enabled: Bool,
|
|
1107
1140
|
active: Bool
|
|
1108
1141
|
) {
|
|
1142
|
+
#if compiler(>=6.2)
|
|
1109
1143
|
if #available(iOS 26.0, *), usesFloatingGlassButtons {
|
|
1110
1144
|
var configuration = active
|
|
1111
1145
|
? UIButton.Configuration.prominentGlass()
|
|
@@ -1135,11 +1169,12 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
1135
1169
|
button.alpha = enabled ? 1 : 0.45
|
|
1136
1170
|
return
|
|
1137
1171
|
}
|
|
1172
|
+
#endif
|
|
1138
1173
|
|
|
1139
1174
|
if resolvedAppearance == .native {
|
|
1140
|
-
button.tintColor = nil
|
|
1141
|
-
button.
|
|
1142
|
-
button.
|
|
1175
|
+
button.tintColor = enabled ? nil : .systemGray
|
|
1176
|
+
button.tintAdjustmentMode = enabled ? .automatic : .normal
|
|
1177
|
+
button.alpha = 1
|
|
1143
1178
|
button.backgroundColor = active
|
|
1144
1179
|
? UIColor.white.withAlphaComponent(0.18)
|
|
1145
1180
|
: .clear
|
|
@@ -1200,12 +1235,14 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
1200
1235
|
}
|
|
1201
1236
|
|
|
1202
1237
|
private func resolvedBlurEffect() -> UIVisualEffect {
|
|
1238
|
+
#if compiler(>=6.2)
|
|
1203
1239
|
if #available(iOS 26.0, *) {
|
|
1204
1240
|
let effect = UIGlassEffect(style: .regular)
|
|
1205
1241
|
effect.isInteractive = true
|
|
1206
1242
|
effect.tintColor = resolvedGlassEffectTintColor
|
|
1207
1243
|
return effect
|
|
1208
1244
|
}
|
|
1245
|
+
#endif
|
|
1209
1246
|
if #available(iOS 13.0, *) {
|
|
1210
1247
|
return UIBlurEffect(style: .systemUltraThinMaterial)
|
|
1211
1248
|
}
|
|
@@ -1268,11 +1305,6 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
1268
1305
|
|
|
1269
1306
|
class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognizerDelegate {
|
|
1270
1307
|
|
|
1271
|
-
private static let updateLog = Logger(
|
|
1272
|
-
subsystem: "com.apollohg.prose-editor",
|
|
1273
|
-
category: "view-command"
|
|
1274
|
-
)
|
|
1275
|
-
|
|
1276
1308
|
// MARK: - Subviews
|
|
1277
1309
|
|
|
1278
1310
|
let richTextView: RichTextEditorView
|
|
@@ -1461,6 +1493,10 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
|
|
|
1461
1493
|
}
|
|
1462
1494
|
}
|
|
1463
1495
|
|
|
1496
|
+
func setAllowImageResizing(_ allowImageResizing: Bool) {
|
|
1497
|
+
richTextView.allowImageResizing = allowImageResizing
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1464
1500
|
private func emitContentHeightIfNeeded(force: Bool = false) {
|
|
1465
1501
|
guard heightBehavior == .autoGrow else { return }
|
|
1466
1502
|
let contentHeight = ceil(richTextView.intrinsicContentSize.height)
|
|
@@ -1513,13 +1549,9 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
|
|
|
1513
1549
|
/// Apply an editor update from JS. Sets the echo-suppression flag so the
|
|
1514
1550
|
/// resulting delegate callback is NOT re-dispatched back to JS.
|
|
1515
1551
|
func applyEditorUpdate(_ updateJson: String) {
|
|
1516
|
-
Self.updateLog.debug("[applyEditorUpdate.begin] bytes=\(updateJson.utf8.count)")
|
|
1517
1552
|
isApplyingJSUpdate = true
|
|
1518
1553
|
richTextView.textView.applyUpdateJSON(updateJson)
|
|
1519
1554
|
isApplyingJSUpdate = false
|
|
1520
|
-
Self.updateLog.debug(
|
|
1521
|
-
"[applyEditorUpdate.end] textState=\(self.richTextView.textView.textStorage.string.count)"
|
|
1522
|
-
)
|
|
1523
1555
|
}
|
|
1524
1556
|
|
|
1525
1557
|
// MARK: - Focus Commands
|
|
@@ -1536,12 +1568,14 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
|
|
|
1536
1568
|
|
|
1537
1569
|
@objc private func textViewDidBeginEditing(_ notification: Notification) {
|
|
1538
1570
|
installOutsideTapRecognizerIfNeeded()
|
|
1571
|
+
richTextView.textView.refreshSelectionVisualState()
|
|
1539
1572
|
refreshMentionQuery()
|
|
1540
1573
|
onFocusChange(["isFocused": true])
|
|
1541
1574
|
}
|
|
1542
1575
|
|
|
1543
1576
|
@objc private func textViewDidEndEditing(_ notification: Notification) {
|
|
1544
1577
|
uninstallOutsideTapRecognizer()
|
|
1578
|
+
richTextView.textView.refreshSelectionVisualState()
|
|
1545
1579
|
clearMentionQueryStateAndHidePopover()
|
|
1546
1580
|
onFocusChange(["isFocused": false])
|
|
1547
1581
|
}
|
|
@@ -1577,10 +1611,12 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
|
|
|
1577
1611
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
1578
1612
|
guard gestureRecognizer === outsideTapGestureRecognizer else { return true }
|
|
1579
1613
|
guard let tapWindow = gestureWindow ?? window else { return true }
|
|
1580
|
-
|
|
1581
|
-
|
|
1614
|
+
let locationInWindow = touch.location(in: tapWindow)
|
|
1615
|
+
let result = shouldHandleOutsideTap(
|
|
1616
|
+
locationInWindow: locationInWindow,
|
|
1582
1617
|
touchedView: touch.view
|
|
1583
1618
|
)
|
|
1619
|
+
return result
|
|
1584
1620
|
}
|
|
1585
1621
|
|
|
1586
1622
|
private func shouldHandleOutsideTap(
|
|
@@ -1624,7 +1660,6 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
|
|
|
1624
1660
|
refreshMentionQuery()
|
|
1625
1661
|
richTextView.refreshRemoteSelections()
|
|
1626
1662
|
guard !isApplyingJSUpdate else { return }
|
|
1627
|
-
Self.updateLog.debug("[didReceiveUpdate] bytes=\(updateJSON.utf8.count)")
|
|
1628
1663
|
onEditorUpdate(["updateJson": updateJSON])
|
|
1629
1664
|
}
|
|
1630
1665
|
|
|
@@ -302,6 +302,9 @@ public class NativeEditorModule: Module {
|
|
|
302
302
|
Prop("heightBehavior") { (view: NativeEditorExpoView, heightBehavior: String) in
|
|
303
303
|
view.setHeightBehavior(heightBehavior)
|
|
304
304
|
}
|
|
305
|
+
Prop("allowImageResizing") { (view: NativeEditorExpoView, allowImageResizing: Bool) in
|
|
306
|
+
view.setAllowImageResizing(allowImageResizing)
|
|
307
|
+
}
|
|
305
308
|
Prop("themeJson") { (view: NativeEditorExpoView, themeJson: String?) in
|
|
306
309
|
view.setThemeJson(themeJson)
|
|
307
310
|
}
|
package/ios/RenderBridge.swift
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import UIKit
|
|
2
2
|
|
|
3
|
+
extension Notification.Name {
|
|
4
|
+
static let editorImageAttachmentDidLoad = Notification.Name(
|
|
5
|
+
"com.apollohg.editor.imageAttachmentDidLoad"
|
|
6
|
+
)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
private enum RenderImageCache {
|
|
10
|
+
static let cache = NSCache<NSString, UIImage>()
|
|
11
|
+
static let queue = DispatchQueue(label: "com.apollohg.editor.image-loader", qos: .userInitiated)
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
// MARK: - Constants
|
|
4
15
|
|
|
5
16
|
/// Custom NSAttributedString attribute keys for editor metadata.
|
|
@@ -188,12 +199,14 @@ final class RenderBridge {
|
|
|
188
199
|
case "voidInline":
|
|
189
200
|
let nodeType = element["nodeType"] as? String ?? ""
|
|
190
201
|
let docPos = jsonUInt32(element["docPos"])
|
|
202
|
+
let attrs = element["attrs"] as? [String: Any] ?? [:]
|
|
191
203
|
if nodeType == "hardBreak" {
|
|
192
204
|
overrideTrailingParagraphSpacing(in: result, paragraphSpacing: 0)
|
|
193
205
|
}
|
|
194
206
|
let attrStr = attributedStringForVoidInline(
|
|
195
207
|
nodeType: nodeType,
|
|
196
208
|
docPos: docPos,
|
|
209
|
+
attrs: attrs,
|
|
197
210
|
baseFont: baseFont,
|
|
198
211
|
textColor: textColor,
|
|
199
212
|
blockStack: blockStack,
|
|
@@ -204,6 +217,7 @@ final class RenderBridge {
|
|
|
204
217
|
case "voidBlock":
|
|
205
218
|
let nodeType = element["nodeType"] as? String ?? ""
|
|
206
219
|
let docPos = jsonUInt32(element["docPos"])
|
|
220
|
+
let attrs = element["attrs"] as? [String: Any] ?? [:]
|
|
207
221
|
|
|
208
222
|
// Add inter-block newline if not the first block.
|
|
209
223
|
if !isFirstBlock {
|
|
@@ -225,6 +239,7 @@ final class RenderBridge {
|
|
|
225
239
|
let attrStr = attributedStringForVoidBlock(
|
|
226
240
|
nodeType: nodeType,
|
|
227
241
|
docPos: docPos,
|
|
242
|
+
elementAttrs: attrs,
|
|
228
243
|
baseFont: baseFont,
|
|
229
244
|
textColor: textColor,
|
|
230
245
|
theme: theme
|
|
@@ -467,6 +482,7 @@ final class RenderBridge {
|
|
|
467
482
|
private static func attributedStringForVoidInline(
|
|
468
483
|
nodeType: String,
|
|
469
484
|
docPos: UInt32,
|
|
485
|
+
attrs _: [String: Any],
|
|
470
486
|
baseFont: UIFont,
|
|
471
487
|
textColor: UIColor,
|
|
472
488
|
blockStack: [BlockContext],
|
|
@@ -511,6 +527,7 @@ final class RenderBridge {
|
|
|
511
527
|
private static func attributedStringForVoidBlock(
|
|
512
528
|
nodeType: String,
|
|
513
529
|
docPos: UInt32,
|
|
530
|
+
elementAttrs: [String: Any],
|
|
514
531
|
baseFont: UIFont,
|
|
515
532
|
textColor: UIColor,
|
|
516
533
|
theme: EditorTheme?
|
|
@@ -532,6 +549,25 @@ final class RenderBridge {
|
|
|
532
549
|
let range = NSRange(location: 0, length: attrStr.length)
|
|
533
550
|
attrStr.addAttributes(attrs, range: range)
|
|
534
551
|
return attrStr
|
|
552
|
+
case "image":
|
|
553
|
+
guard let source = (elementAttrs["src"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
554
|
+
!source.isEmpty
|
|
555
|
+
else {
|
|
556
|
+
return NSAttributedString(
|
|
557
|
+
string: LayoutConstants.objectReplacementCharacter,
|
|
558
|
+
attributes: attrs
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
let attachment = BlockImageAttachment(
|
|
562
|
+
source: source,
|
|
563
|
+
placeholderTint: textColor,
|
|
564
|
+
preferredWidth: jsonCGFloat(elementAttrs["width"]),
|
|
565
|
+
preferredHeight: jsonCGFloat(elementAttrs["height"])
|
|
566
|
+
)
|
|
567
|
+
let attrStr = NSMutableAttributedString(attachment: attachment)
|
|
568
|
+
let range = NSRange(location: 0, length: attrStr.length)
|
|
569
|
+
attrStr.addAttributes(attrs, range: range)
|
|
570
|
+
return attrStr
|
|
535
571
|
default:
|
|
536
572
|
// Unknown void block: render as object replacement character.
|
|
537
573
|
return NSAttributedString(
|
|
@@ -698,6 +734,21 @@ final class RenderBridge {
|
|
|
698
734
|
return 0
|
|
699
735
|
}
|
|
700
736
|
|
|
737
|
+
/// Extract a positive `CGFloat` from a JSON value produced by `JSONSerialization`.
|
|
738
|
+
static func jsonCGFloat(_ value: Any?) -> CGFloat? {
|
|
739
|
+
if let number = value as? NSNumber {
|
|
740
|
+
let resolved = CGFloat(truncating: number)
|
|
741
|
+
return resolved > 0 ? resolved : nil
|
|
742
|
+
}
|
|
743
|
+
if let string = value as? String,
|
|
744
|
+
let resolved = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)),
|
|
745
|
+
resolved > 0
|
|
746
|
+
{
|
|
747
|
+
return CGFloat(resolved)
|
|
748
|
+
}
|
|
749
|
+
return nil
|
|
750
|
+
}
|
|
751
|
+
|
|
701
752
|
private static func defaultAttributes(
|
|
702
753
|
baseFont: UIFont,
|
|
703
754
|
textColor: UIColor
|
|
@@ -1003,3 +1054,160 @@ final class HorizontalRuleAttachment: NSTextAttachment {
|
|
|
1003
1054
|
}
|
|
1004
1055
|
}
|
|
1005
1056
|
}
|
|
1057
|
+
|
|
1058
|
+
final class BlockImageAttachment: NSTextAttachment {
|
|
1059
|
+
private let source: String
|
|
1060
|
+
private let placeholderTint: UIColor
|
|
1061
|
+
private var preferredWidth: CGFloat?
|
|
1062
|
+
private var preferredHeight: CGFloat?
|
|
1063
|
+
private var loadedImage: UIImage?
|
|
1064
|
+
|
|
1065
|
+
init(
|
|
1066
|
+
source: String,
|
|
1067
|
+
placeholderTint: UIColor,
|
|
1068
|
+
preferredWidth: CGFloat?,
|
|
1069
|
+
preferredHeight: CGFloat?
|
|
1070
|
+
) {
|
|
1071
|
+
self.source = source
|
|
1072
|
+
self.placeholderTint = placeholderTint
|
|
1073
|
+
self.preferredWidth = preferredWidth
|
|
1074
|
+
self.preferredHeight = preferredHeight
|
|
1075
|
+
super.init(data: nil, ofType: nil)
|
|
1076
|
+
loadImageIfNeeded()
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
required init?(coder: NSCoder) {
|
|
1080
|
+
return nil
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
func setPreferredSize(width: CGFloat, height: CGFloat) {
|
|
1084
|
+
preferredWidth = width
|
|
1085
|
+
preferredHeight = height
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
func previewImage() -> UIImage? {
|
|
1089
|
+
loadedImage ?? image
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
override func attachmentBounds(
|
|
1093
|
+
for textContainer: NSTextContainer?,
|
|
1094
|
+
proposedLineFragment lineFrag: CGRect,
|
|
1095
|
+
glyphPosition position: CGPoint,
|
|
1096
|
+
characterIndex charIndex: Int
|
|
1097
|
+
) -> CGRect {
|
|
1098
|
+
let lineFragmentWidth = lineFrag.width.isFinite ? lineFrag.width : 0
|
|
1099
|
+
let containerWidth = textContainer.map {
|
|
1100
|
+
max(0, $0.size.width - ($0.lineFragmentPadding * 2))
|
|
1101
|
+
} ?? 0
|
|
1102
|
+
let widthCandidates = [lineFragmentWidth, containerWidth].filter { $0.isFinite && $0 > 0 }
|
|
1103
|
+
let maxWidth = max(160, widthCandidates.min() ?? 160)
|
|
1104
|
+
let fallbackAspectRatio = loadedImage.flatMap { image -> CGFloat? in
|
|
1105
|
+
let imageSize = image.size
|
|
1106
|
+
guard imageSize.width > 0, imageSize.height > 0 else { return nil }
|
|
1107
|
+
return imageSize.height / imageSize.width
|
|
1108
|
+
} ?? 0.56
|
|
1109
|
+
|
|
1110
|
+
var resolvedWidth = preferredWidth
|
|
1111
|
+
var resolvedHeight = preferredHeight
|
|
1112
|
+
|
|
1113
|
+
if resolvedWidth == nil, resolvedHeight == nil, let loadedImage {
|
|
1114
|
+
let imageSize = loadedImage.size
|
|
1115
|
+
if imageSize.width > 0, imageSize.height > 0 {
|
|
1116
|
+
resolvedWidth = imageSize.width
|
|
1117
|
+
resolvedHeight = imageSize.height
|
|
1118
|
+
}
|
|
1119
|
+
} else if resolvedWidth == nil, let resolvedHeight {
|
|
1120
|
+
resolvedWidth = resolvedHeight / fallbackAspectRatio
|
|
1121
|
+
} else if resolvedHeight == nil, let resolvedWidth {
|
|
1122
|
+
resolvedHeight = resolvedWidth * fallbackAspectRatio
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
let width = max(1, resolvedWidth ?? maxWidth)
|
|
1126
|
+
let height = max(1, resolvedHeight ?? min(180, maxWidth * fallbackAspectRatio))
|
|
1127
|
+
let scale = min(1, maxWidth / width)
|
|
1128
|
+
return CGRect(x: 0, y: 0, width: width * scale, height: height * scale)
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
override func image(
|
|
1132
|
+
forBounds imageBounds: CGRect,
|
|
1133
|
+
textContainer: NSTextContainer?,
|
|
1134
|
+
characterIndex charIndex: Int
|
|
1135
|
+
) -> UIImage? {
|
|
1136
|
+
if let loadedImage {
|
|
1137
|
+
return loadedImage
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
let renderer = UIGraphicsImageRenderer(bounds: imageBounds)
|
|
1141
|
+
return renderer.image { _ in
|
|
1142
|
+
let path = UIBezierPath(roundedRect: imageBounds, cornerRadius: 12)
|
|
1143
|
+
UIColor.secondarySystemFill.setFill()
|
|
1144
|
+
path.fill()
|
|
1145
|
+
|
|
1146
|
+
let iconSize = min(imageBounds.width, imageBounds.height) * 0.28
|
|
1147
|
+
let iconOrigin = CGPoint(
|
|
1148
|
+
x: imageBounds.midX - (iconSize / 2),
|
|
1149
|
+
y: imageBounds.midY - (iconSize / 2)
|
|
1150
|
+
)
|
|
1151
|
+
let iconRect = CGRect(origin: iconOrigin, size: CGSize(width: iconSize, height: iconSize))
|
|
1152
|
+
|
|
1153
|
+
if #available(iOS 13.0, *) {
|
|
1154
|
+
let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .medium)
|
|
1155
|
+
let icon = UIImage(systemName: "photo", withConfiguration: config)?
|
|
1156
|
+
.withTintColor(placeholderTint.withAlphaComponent(0.7), renderingMode: .alwaysOriginal)
|
|
1157
|
+
icon?.draw(in: iconRect)
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private func loadImageIfNeeded() {
|
|
1163
|
+
if let cached = RenderImageCache.cache.object(forKey: source as NSString) {
|
|
1164
|
+
loadedImage = cached
|
|
1165
|
+
image = cached
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if let inlineData = Self.decodeDataURL(source),
|
|
1170
|
+
let image = UIImage(data: inlineData)
|
|
1171
|
+
{
|
|
1172
|
+
RenderImageCache.cache.setObject(image, forKey: source as NSString)
|
|
1173
|
+
loadedImage = image
|
|
1174
|
+
self.image = image
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
guard let url = URL(string: source) else { return }
|
|
1179
|
+
RenderImageCache.queue.async { [weak self] in
|
|
1180
|
+
guard let self else { return }
|
|
1181
|
+
let data: Data?
|
|
1182
|
+
if url.isFileURL {
|
|
1183
|
+
data = try? Data(contentsOf: url)
|
|
1184
|
+
} else {
|
|
1185
|
+
data = try? Data(contentsOf: url)
|
|
1186
|
+
}
|
|
1187
|
+
guard let data,
|
|
1188
|
+
let image = UIImage(data: data)
|
|
1189
|
+
else {
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
RenderImageCache.cache.setObject(image, forKey: self.source as NSString)
|
|
1193
|
+
DispatchQueue.main.async {
|
|
1194
|
+
self.loadedImage = image
|
|
1195
|
+
self.image = image
|
|
1196
|
+
NotificationCenter.default.post(name: .editorImageAttachmentDidLoad, object: self)
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private static func decodeDataURL(_ source: String) -> Data? {
|
|
1202
|
+
let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1203
|
+
guard trimmed.lowercased().hasPrefix("data:image/"),
|
|
1204
|
+
let commaIndex = trimmed.firstIndex(of: ",")
|
|
1205
|
+
else {
|
|
1206
|
+
return nil
|
|
1207
|
+
}
|
|
1208
|
+
let metadata = String(trimmed[..<commaIndex]).lowercased()
|
|
1209
|
+
let payload = String(trimmed[trimmed.index(after: commaIndex)...])
|
|
1210
|
+
guard metadata.contains(";base64") else { return nil }
|
|
1211
|
+
return Data(base64Encoded: payload, options: [.ignoreUnknownCharacters])
|
|
1212
|
+
}
|
|
1213
|
+
}
|