@apollohg/react-native-prose-editor 0.5.2 → 0.5.4

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.
@@ -8,32 +8,32 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>libeditor_core.a</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64</string>
11
+ <string>ios-arm64_x86_64-simulator</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>libeditor_core.a</string>
14
14
  <key>SupportedArchitectures</key>
15
15
  <array>
16
16
  <string>arm64</string>
17
+ <string>x86_64</string>
17
18
  </array>
18
19
  <key>SupportedPlatform</key>
19
20
  <string>ios</string>
21
+ <key>SupportedPlatformVariant</key>
22
+ <string>simulator</string>
20
23
  </dict>
21
24
  <dict>
22
25
  <key>BinaryPath</key>
23
26
  <string>libeditor_core.a</string>
24
27
  <key>LibraryIdentifier</key>
25
- <string>ios-arm64_x86_64-simulator</string>
28
+ <string>ios-arm64</string>
26
29
  <key>LibraryPath</key>
27
30
  <string>libeditor_core.a</string>
28
31
  <key>SupportedArchitectures</key>
29
32
  <array>
30
33
  <string>arm64</string>
31
- <string>x86_64</string>
32
34
  </array>
33
35
  <key>SupportedPlatform</key>
34
36
  <string>ios</string>
35
- <key>SupportedPlatformVariant</key>
36
- <string>simulator</string>
37
37
  </dict>
38
38
  </array>
39
39
  <key>CFBundlePackageType</key>
@@ -80,12 +80,14 @@ struct EditorTextStyle {
80
80
 
81
81
  struct EditorListTheme {
82
82
  var indent: CGFloat?
83
+ var baseIndentMultiplier: CGFloat?
83
84
  var itemSpacing: CGFloat?
84
85
  var markerColor: UIColor?
85
86
  var markerScale: CGFloat?
86
87
 
87
88
  init(dictionary: [String: Any]) {
88
89
  indent = EditorTheme.cgFloat(dictionary["indent"])
90
+ baseIndentMultiplier = EditorTheme.cgFloat(dictionary["baseIndentMultiplier"])
89
91
  itemSpacing = EditorTheme.cgFloat(dictionary["itemSpacing"])
90
92
  markerColor = EditorTheme.color(from: dictionary["markerColor"])
91
93
  markerScale = EditorTheme.cgFloat(dictionary["markerScale"])
@@ -194,6 +196,8 @@ struct EditorToolbarTheme {
194
196
  var borderColor: UIColor?
195
197
  var borderWidth: CGFloat?
196
198
  var borderRadius: CGFloat?
199
+ var marginTop: CGFloat?
200
+ var showTopBorder: Bool?
197
201
  var keyboardOffset: CGFloat?
198
202
  var horizontalInset: CGFloat?
199
203
  var separatorColor: UIColor?
@@ -209,6 +213,8 @@ struct EditorToolbarTheme {
209
213
  borderColor = EditorTheme.color(from: dictionary["borderColor"])
210
214
  borderWidth = EditorTheme.cgFloat(dictionary["borderWidth"])
211
215
  borderRadius = EditorTheme.cgFloat(dictionary["borderRadius"])
216
+ marginTop = EditorTheme.cgFloat(dictionary["marginTop"])
217
+ showTopBorder = dictionary["showTopBorder"] as? Bool
212
218
  keyboardOffset = EditorTheme.cgFloat(dictionary["keyboardOffset"])
213
219
  horizontalInset = EditorTheme.cgFloat(dictionary["horizontalInset"])
214
220
  separatorColor = EditorTheme.color(from: dictionary["separatorColor"])
@@ -268,6 +268,13 @@ public class NativeEditorModule: Module {
268
268
  }
269
269
  return editorSetJson(id: editorId, json: json)
270
270
  }
271
+ Function("renderDocumentHtml") { (configJson: String, html: String) -> String in
272
+ let editorId = editorCreate(configJson: configJson)
273
+ defer {
274
+ editorDestroy(id: editorId)
275
+ }
276
+ return editorSetHtml(id: editorId, html: html)
277
+ }
271
278
  Function("editorReplaceHtml") { (id: Int, html: String) -> String in
272
279
  editorReplaceHtml(id: UInt64(id), html: html)
273
280
  }
@@ -375,7 +382,7 @@ public class NativeEditorModule: Module {
375
382
 
376
383
  View(NativeProseViewerExpoView.self) {
377
384
  ViewName("NativeProseViewer")
378
- Events("onContentHeightChange", "onPressMention")
385
+ Events("onContentHeightChange", "onPressLink", "onPressMention")
379
386
 
380
387
  Prop("renderJson") { (view: NativeProseViewerExpoView, renderJson: String?) in
381
388
  view.setRenderJson(renderJson)
@@ -383,6 +390,16 @@ public class NativeEditorModule: Module {
383
390
  Prop("themeJson") { (view: NativeProseViewerExpoView, themeJson: String?) in
384
391
  view.setThemeJson(themeJson)
385
392
  }
393
+ Prop("collapsesWhenEmpty") {
394
+ (view: NativeProseViewerExpoView, collapsesWhenEmpty: Bool?) in
395
+ view.setCollapsesWhenEmpty(collapsesWhenEmpty)
396
+ }
397
+ Prop("enableLinkTaps") { (view: NativeProseViewerExpoView, enableLinkTaps: Bool?) in
398
+ view.setEnableLinkTaps(enableLinkTaps)
399
+ }
400
+ Prop("interceptLinkTaps") { (view: NativeProseViewerExpoView, interceptLinkTaps: Bool?) in
401
+ view.setInterceptLinkTaps(interceptLinkTaps)
402
+ }
386
403
  }
387
404
  }
388
405
  }
@@ -3,6 +3,7 @@ import UIKit
3
3
 
4
4
  final class NativeProseViewerExpoView: ExpoView {
5
5
  let onContentHeightChange = EventDispatcher()
6
+ let onPressLink = EventDispatcher()
6
7
  let onPressMention = EventDispatcher()
7
8
 
8
9
  private let textView = EditorTextView(frame: .zero, textContainer: nil)
@@ -11,11 +12,15 @@ final class NativeProseViewerExpoView: ExpoView {
11
12
  private var lastEmittedContentHeight: CGFloat = 0
12
13
  private var lastMeasuredWidth: CGFloat = 0
13
14
  private var allowContentHeightShrink = true
15
+ private var collapsesWhenEmpty = true
16
+ private var isCollapsedEmptyContent = false
17
+ private var enableLinkTaps = true
18
+ private var interceptLinkTaps = false
14
19
 
15
- private lazy var mentionTapRecognizer: UITapGestureRecognizer = {
20
+ private lazy var interactiveTapRecognizer: UITapGestureRecognizer = {
16
21
  let recognizer = UITapGestureRecognizer(
17
22
  target: self,
18
- action: #selector(handleMentionTap(_:))
23
+ action: #selector(handleInteractiveTap(_:))
19
24
  )
20
25
  recognizer.cancelsTouchesInView = false
21
26
  return recognizer
@@ -36,10 +41,28 @@ final class NativeProseViewerExpoView: ExpoView {
36
41
  textView.onHeightMayChange = { [weak self] measuredHeight in
37
42
  self?.emitContentHeightIfNeeded(measuredHeight: measuredHeight, force: true)
38
43
  }
39
- textView.addGestureRecognizer(mentionTapRecognizer)
44
+ textView.addGestureRecognizer(interactiveTapRecognizer)
40
45
  addSubview(textView)
41
46
  }
42
47
 
48
+ func setEnableLinkTaps(_ enabled: Bool?) {
49
+ enableLinkTaps = enabled ?? true
50
+ }
51
+
52
+ func setInterceptLinkTaps(_ intercept: Bool?) {
53
+ interceptLinkTaps = intercept ?? false
54
+ }
55
+
56
+ func setCollapsesWhenEmpty(_ collapses: Bool?) {
57
+ let nextValue = collapses ?? true
58
+ guard collapsesWhenEmpty != nextValue else { return }
59
+ collapsesWhenEmpty = nextValue
60
+ allowContentHeightShrink = true
61
+ updateCollapsedEmptyState()
62
+ setNeedsLayout()
63
+ emitContentHeightIfNeeded(force: true)
64
+ }
65
+
43
66
  func setRenderJson(_ renderJson: String?) {
44
67
  guard lastRenderJSON != renderJson else { return }
45
68
  lastRenderJSON = renderJson
@@ -60,6 +83,9 @@ final class NativeProseViewerExpoView: ExpoView {
60
83
  }
61
84
 
62
85
  override var intrinsicContentSize: CGSize {
86
+ if isCollapsedEmptyContent {
87
+ return CGSize(width: UIView.noIntrinsicMetric, height: 0)
88
+ }
63
89
  guard lastEmittedContentHeight > 0 else {
64
90
  return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
65
91
  }
@@ -68,8 +94,13 @@ final class NativeProseViewerExpoView: ExpoView {
68
94
 
69
95
  override func layoutSubviews() {
70
96
  super.layoutSubviews()
71
- textView.frame = bounds
72
- textView.updateAutoGrowHostHeight(bounds.height)
97
+ if isCollapsedEmptyContent {
98
+ textView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 0)
99
+ textView.updateAutoGrowHostHeight(0)
100
+ } else {
101
+ textView.frame = bounds
102
+ textView.updateAutoGrowHostHeight(bounds.height)
103
+ }
73
104
 
74
105
  let currentWidth = ceil(bounds.width)
75
106
  guard abs(currentWidth - lastMeasuredWidth) > 0.5 else { return }
@@ -78,21 +109,36 @@ final class NativeProseViewerExpoView: ExpoView {
78
109
  }
79
110
 
80
111
  private func applyRenderJSON() {
112
+ updateCollapsedEmptyState()
81
113
  textView.applyRenderJSON(lastRenderJSON ?? "[]")
114
+ textView.isHidden = isCollapsedEmptyContent
115
+ invalidateIntrinsicContentSize()
116
+ setNeedsLayout()
82
117
  emitContentHeightIfNeeded(force: true)
83
118
  }
84
119
 
120
+ private func updateCollapsedEmptyState() {
121
+ isCollapsedEmptyContent = collapsesWhenEmpty
122
+ && Self.renderJsonContainsOnlyEmptyParagraphs(lastRenderJSON ?? "[]")
123
+ textView.isHidden = isCollapsedEmptyContent
124
+ }
125
+
85
126
  private func emitContentHeightIfNeeded(
86
127
  measuredHeight: CGFloat? = nil,
87
128
  force: Bool = false
88
129
  ) {
89
- let resolvedWidth = bounds.width > 0
90
- ? bounds.width
91
- : (superview?.bounds.width ?? UIScreen.main.bounds.width)
92
- let fittedHeight = measuredHeight
93
- ?? textView.measuredAutoGrowHeightForTesting(width: resolvedWidth)
94
- let contentHeight = ceil(fittedHeight)
95
- guard contentHeight > 0 else { return }
130
+ let contentHeight: CGFloat
131
+ if isCollapsedEmptyContent {
132
+ contentHeight = 0
133
+ } else {
134
+ let resolvedWidth = bounds.width > 0
135
+ ? bounds.width
136
+ : (superview?.bounds.width ?? UIScreen.main.bounds.width)
137
+ let fittedHeight = measuredHeight
138
+ ?? textView.measuredAutoGrowHeightForTesting(width: resolvedWidth)
139
+ contentHeight = ceil(fittedHeight)
140
+ guard contentHeight > 0 else { return }
141
+ }
96
142
  guard allowContentHeightShrink || contentHeight >= lastEmittedContentHeight else { return }
97
143
  allowContentHeightShrink = false
98
144
  guard force || abs(contentHeight - lastEmittedContentHeight) > 0.5 else { return }
@@ -101,20 +147,32 @@ final class NativeProseViewerExpoView: ExpoView {
101
147
  onContentHeightChange(["contentHeight": contentHeight])
102
148
  }
103
149
 
104
- @objc private func handleMentionTap(_ recognizer: UITapGestureRecognizer) {
105
- guard recognizer.state == .ended,
106
- let mention = mentionHit(at: recognizer.location(in: textView))
107
- else {
150
+ @objc private func handleInteractiveTap(_ recognizer: UITapGestureRecognizer) {
151
+ guard recognizer.state == .ended else {
152
+ return
153
+ }
154
+
155
+ let location = recognizer.location(in: textView)
156
+ if enableLinkTaps, let link = linkHit(at: location) {
157
+ if interceptLinkTaps {
158
+ onPressLink([
159
+ "href": link.href,
160
+ "text": link.text,
161
+ ])
162
+ } else {
163
+ openLink(link.href)
164
+ }
108
165
  return
109
166
  }
110
167
 
168
+ guard let mention = mentionHit(at: location) else { return }
111
169
  onPressMention([
112
170
  "docPos": mention.docPos,
113
171
  "label": mention.label,
114
172
  ])
115
173
  }
116
174
 
117
- private func mentionHit(at location: CGPoint) -> (docPos: Int, label: String)? {
175
+ private func characterIndex(at location: CGPoint) -> Int? {
118
176
  let textStorage = textView.textStorage
119
177
  guard textStorage.length > 0 else { return nil }
120
178
 
@@ -133,6 +191,26 @@ final class NativeProseViewerExpoView: ExpoView {
133
191
  guard glyphIndex < layoutManager.numberOfGlyphs else { return nil }
134
192
  let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex)
135
193
  guard characterIndex < textStorage.length else { return nil }
194
+ return characterIndex
195
+ }
196
+
197
+ private func linkHit(at location: CGPoint) -> (href: String, text: String)? {
198
+ let textStorage = textView.textStorage
199
+ guard let characterIndex = characterIndex(at: location) else { return nil }
200
+
201
+ var effectiveRange = NSRange(location: 0, length: 0)
202
+ let attrs = textStorage.attributes(at: characterIndex, effectiveRange: &effectiveRange)
203
+ guard let href = attrs[RenderBridgeAttributes.linkHref] as? String, !href.isEmpty else {
204
+ return nil
205
+ }
206
+
207
+ let text = (textStorage.string as NSString).substring(with: effectiveRange)
208
+ return (href: href, text: text)
209
+ }
210
+
211
+ private func mentionHit(at location: CGPoint) -> (docPos: Int, label: String)? {
212
+ let textStorage = textView.textStorage
213
+ guard let characterIndex = characterIndex(at: location) else { return nil }
136
214
 
137
215
  var effectiveRange = NSRange(location: 0, length: 0)
138
216
  let attrs = textStorage.attributes(at: characterIndex, effectiveRange: &effectiveRange)
@@ -146,4 +224,61 @@ final class NativeProseViewerExpoView: ExpoView {
146
224
  let label = (textStorage.string as NSString).substring(with: effectiveRange)
147
225
  return (docPos: docPos, label: label)
148
226
  }
227
+
228
+ private func openLink(_ href: String) {
229
+ guard let url = URL(string: href) else { return }
230
+ UIApplication.shared.open(url, options: [:], completionHandler: nil)
231
+ }
232
+
233
+ static func renderJsonContainsOnlyEmptyParagraphs(_ renderJson: String) -> Bool {
234
+ guard let data = renderJson.data(using: .utf8),
235
+ let elements = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
236
+ else {
237
+ return false
238
+ }
239
+
240
+ if elements.isEmpty {
241
+ return true
242
+ }
243
+
244
+ var hasParagraph = false
245
+ var paragraphIsOpen = false
246
+
247
+ for element in elements {
248
+ guard let type = element["type"] as? String else {
249
+ return false
250
+ }
251
+
252
+ switch type {
253
+ case "blockStart":
254
+ guard !paragraphIsOpen,
255
+ element["nodeType"] as? String == "paragraph",
256
+ (element["depth"] as? NSNumber)?.intValue == 0
257
+ else {
258
+ return false
259
+ }
260
+ paragraphIsOpen = true
261
+ hasParagraph = true
262
+
263
+ case "textRun":
264
+ guard paragraphIsOpen,
265
+ let text = element["text"] as? String,
266
+ text.allSatisfy({ $0 == "\u{200B}" })
267
+ else {
268
+ return false
269
+ }
270
+
271
+ case "blockEnd":
272
+ guard paragraphIsOpen else {
273
+ return false
274
+ }
275
+ paragraphIsOpen = false
276
+
277
+ default:
278
+ return false
279
+ }
280
+ }
281
+
282
+ return hasParagraph && !paragraphIsOpen
283
+ }
149
284
  }
@@ -107,6 +107,9 @@ enum RenderBridgeAttributes {
107
107
  /// Marks synthetic zero-width placeholders used only for UIKit layout.
108
108
  static let syntheticPlaceholder = NSAttributedString.Key("com.apollohg.editor.syntheticPlaceholder")
109
109
 
110
+ /// Stores the link href for visually styled link text without enabling UITextView's default link interaction.
111
+ static let linkHref = NSAttributedString.Key("com.apollohg.editor.linkHref")
112
+
110
113
  /// Stores the owning top-level document child index for partial native patching.
111
114
  static let topLevelChildIndex = NSAttributedString.Key("com.apollohg.editor.topLevelChildIndex")
112
115
  }
@@ -564,11 +567,11 @@ final class RenderBridge {
564
567
  var traits: UIFontDescriptor.SymbolicTraits = []
565
568
  var useMonospace = false
566
569
  for mark in marks {
570
+ let markObject = mark as? [String: Any]
567
571
  let markType: String
568
572
  if let markName = mark as? String {
569
573
  markType = markName
570
- } else if let markObject = mark as? [String: Any],
571
- let resolvedType = markObject["type"] as? String {
574
+ } else if let resolvedType = markObject?["type"] as? String {
572
575
  markType = resolvedType
573
576
  } else {
574
577
  continue
@@ -588,6 +591,9 @@ final class RenderBridge {
588
591
  case "link":
589
592
  attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
590
593
  attrs[.foregroundColor] = UIColor.systemBlue
594
+ if let href = markObject?["href"] as? String, !href.isEmpty {
595
+ attrs[RenderBridgeAttributes.linkHref] = href
596
+ }
591
597
  default:
592
598
  break
593
599
  }
@@ -841,8 +847,13 @@ final class RenderBridge {
841
847
  (theme?.blockquote?.markerGap ?? LayoutConstants.blockquoteMarkerGap)
842
848
  + (theme?.blockquote?.borderWidth ?? LayoutConstants.blockquoteBorderWidth)
843
849
  )
850
+ let listBaseIndentMultiplier = max(theme?.list?.baseIndentMultiplier ?? 1, 0)
851
+ let listBaseIndentAdjustment = context.listContext != nil
852
+ ? ((listBaseIndentMultiplier - 1) * indentPerDepth)
853
+ : 0
844
854
  let baseIndent = (CGFloat(context.depth) * indentPerDepth)
845
855
  - (quoteDepth * indentPerDepth)
856
+ + listBaseIndentAdjustment
846
857
  + (quoteDepth * quoteIndent)
847
858
 
848
859
  if context.listContext != nil {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Native rich text editor with Rust core for React Native",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/apollohg/react-native-prose-editor",