@apollohg/react-native-prose-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +143 -0
  3. package/android/build.gradle +39 -0
  4. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  5. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
  11. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
  14. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/EditorAddons.swift +228 -0
  19. package/ios/EditorCore.xcframework/Info.plist +44 -0
  20. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  21. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  22. package/ios/EditorLayoutManager.swift +254 -0
  23. package/ios/EditorTheme.swift +372 -0
  24. package/ios/Generated_editor_core.swift +1143 -0
  25. package/ios/NativeEditorExpoView.swift +1417 -0
  26. package/ios/NativeEditorModule.swift +263 -0
  27. package/ios/PositionBridge.swift +278 -0
  28. package/ios/ReactNativeProseEditor.podspec +49 -0
  29. package/ios/RenderBridge.swift +825 -0
  30. package/ios/RichTextEditorView.swift +1559 -0
  31. package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
  32. package/ios/editor_coreFFI/module.modulemap +7 -0
  33. package/ios/editor_coreFFI.h +904 -0
  34. package/ios/editor_coreFFI.modulemap +7 -0
  35. package/package.json +66 -0
  36. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  37. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  38. package/rust/android/x86_64/libeditor_core.so +0 -0
  39. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
  40. package/src/EditorTheme.ts +130 -0
  41. package/src/EditorToolbar.tsx +620 -0
  42. package/src/NativeEditorBridge.ts +607 -0
  43. package/src/NativeRichTextEditor.tsx +951 -0
  44. package/src/addons.ts +158 -0
  45. package/src/index.ts +63 -0
  46. package/src/schemas.ts +153 -0
  47. package/src/useNativeEditor.ts +173 -0
@@ -0,0 +1,254 @@
1
+ import UIKit
2
+ import CoreText
3
+
4
+ /// Draws list markers visually in the gutter without inserting them into the
5
+ /// editable text storage. This keeps UIKit paragraph-start behaviors, such as
6
+ /// sentence auto-capitalization, working naturally inside list items.
7
+ final class EditorLayoutManager: NSLayoutManager {
8
+
9
+ override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
10
+ super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
11
+
12
+ guard let textStorage, glyphsToShow.length > 0 else { return }
13
+
14
+ let characterRange = characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
15
+ let nsString = textStorage.string as NSString
16
+ var drawnParagraphStarts = Set<Int>()
17
+
18
+ textStorage.enumerateAttribute(
19
+ RenderBridgeAttributes.listMarkerContext,
20
+ in: characterRange,
21
+ options: []
22
+ ) { value, range, _ in
23
+ guard range.length > 0, let listContext = value as? [String: Any] else { return }
24
+
25
+ let paragraphRange = nsString.paragraphRange(for: NSRange(location: range.location, length: 0))
26
+ let paragraphStart = paragraphRange.location
27
+ guard !Self.isParagraphStartCreatedByHardBreak(paragraphStart, in: textStorage) else {
28
+ return
29
+ }
30
+ guard drawnParagraphStarts.insert(paragraphStart).inserted else { return }
31
+
32
+ self.drawListMarker(
33
+ listContext: listContext,
34
+ paragraphStart: paragraphStart,
35
+ origin: origin,
36
+ textStorage: textStorage
37
+ )
38
+ }
39
+ }
40
+
41
+ private func drawListMarker(
42
+ listContext: [String: Any],
43
+ paragraphStart: Int,
44
+ origin: CGPoint,
45
+ textStorage: NSTextStorage
46
+ ) {
47
+ guard paragraphStart < textStorage.length else { return }
48
+
49
+ let glyphIndex = glyphIndexForCharacter(at: paragraphStart)
50
+ guard glyphIndex < numberOfGlyphs else { return }
51
+
52
+ var lineGlyphRange = NSRange()
53
+ let usedRect = lineFragmentUsedRect(forGlyphAt: glyphIndex, effectiveRange: &lineGlyphRange)
54
+ let lineFragmentRect = self.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil)
55
+ let attrs = textStorage.attributes(at: paragraphStart, effectiveRange: nil)
56
+
57
+ let baseFont = attrs[.font] as? UIFont ?? .systemFont(ofSize: 16)
58
+ let textColor = attrs[RenderBridgeAttributes.listMarkerColor] as? UIColor
59
+ ?? attrs[.foregroundColor] as? UIColor
60
+ ?? .label
61
+ let markerScale = (attrs[RenderBridgeAttributes.listMarkerScale] as? NSNumber)
62
+ .map { CGFloat(truncating: $0) }
63
+ ?? LayoutConstants.unorderedListMarkerFontScale
64
+ let markerWidth = (attrs[RenderBridgeAttributes.listMarkerWidth] as? NSNumber)
65
+ .map { CGFloat(truncating: $0) }
66
+ ?? LayoutConstants.listMarkerWidth
67
+ let ordered = (listContext["ordered"] as? NSNumber)?.boolValue ?? false
68
+
69
+ let glyphLocation = location(forGlyphAt: glyphIndex)
70
+ let baselineY = lineFragmentRect.minY + glyphLocation.y
71
+
72
+ if ordered {
73
+ let markerFont = markerFont(
74
+ for: listContext,
75
+ baseFont: baseFont,
76
+ markerScale: markerScale
77
+ )
78
+ let markerText = RenderBridge.listMarkerString(listContext: listContext)
79
+ let markerOrigin = Self.orderedMarkerDrawingOrigin(
80
+ usedRect: usedRect,
81
+ lineFragmentRect: lineFragmentRect,
82
+ markerWidth: markerWidth,
83
+ baselineY: baselineY,
84
+ markerFont: markerFont,
85
+ markerText: markerText,
86
+ origin: origin
87
+ )
88
+ let markerAttrs: [NSAttributedString.Key: Any] = [
89
+ .font: markerFont,
90
+ .foregroundColor: textColor,
91
+ ]
92
+ NSAttributedString(string: markerText, attributes: markerAttrs).draw(at: markerOrigin)
93
+ return
94
+ }
95
+
96
+ let bulletRect = Self.unorderedBulletDrawingRect(
97
+ usedRect: usedRect,
98
+ lineFragmentRect: lineFragmentRect,
99
+ markerWidth: markerWidth,
100
+ baselineY: baselineY,
101
+ baseFont: baseFont,
102
+ markerScale: markerScale,
103
+ origin: origin
104
+ )
105
+ let path = UIBezierPath(ovalIn: bulletRect)
106
+ textColor.setFill()
107
+ path.fill()
108
+ }
109
+
110
+ static func markerParagraphStyle(from attrs: [NSAttributedString.Key: Any]) -> NSMutableParagraphStyle {
111
+ let markerStyle = NSMutableParagraphStyle()
112
+ let sourceStyle = attrs[.paragraphStyle] as? NSParagraphStyle
113
+
114
+ markerStyle.minimumLineHeight = sourceStyle?.minimumLineHeight ?? 0
115
+ markerStyle.maximumLineHeight = sourceStyle?.maximumLineHeight ?? 0
116
+ markerStyle.lineHeightMultiple = sourceStyle?.lineHeightMultiple ?? 0
117
+ markerStyle.baseWritingDirection = sourceStyle?.baseWritingDirection ?? .natural
118
+ markerStyle.alignment = .right
119
+ markerStyle.lineBreakMode = .byClipping
120
+ markerStyle.firstLineHeadIndent = 0
121
+ markerStyle.headIndent = 0
122
+ markerStyle.tailIndent = 0
123
+
124
+ return markerStyle
125
+ }
126
+
127
+ static func markerDrawingRect(
128
+ usedRect: CGRect,
129
+ lineFragmentRect: CGRect,
130
+ markerWidth: CGFloat,
131
+ baselineY: CGFloat,
132
+ markerFont: UIFont,
133
+ origin: CGPoint
134
+ ) -> CGRect {
135
+ let typographicHeight = markerFont.ascender - markerFont.descender
136
+ let leading = max(markerFont.lineHeight - typographicHeight, 0)
137
+ let topY = baselineY - markerFont.ascender - (leading / 2.0)
138
+ let referenceRect = usedRect.height > 0 ? usedRect : lineFragmentRect
139
+ return CGRect(
140
+ x: origin.x + referenceRect.minX - markerWidth,
141
+ y: origin.y + topY,
142
+ width: markerWidth - 4.0,
143
+ height: markerFont.lineHeight
144
+ )
145
+ }
146
+
147
+ static func orderedMarkerDrawingOrigin(
148
+ usedRect: CGRect,
149
+ lineFragmentRect: CGRect,
150
+ markerWidth: CGFloat,
151
+ baselineY: CGFloat,
152
+ markerFont: UIFont,
153
+ markerText: String,
154
+ origin: CGPoint
155
+ ) -> CGPoint {
156
+ let referenceRect = usedRect.height > 0 ? usedRect : lineFragmentRect
157
+ let visibleMarkerText = markerText.trimmingCharacters(in: .whitespaces)
158
+ let markerSize = (visibleMarkerText as NSString).size(withAttributes: [
159
+ .font: markerFont,
160
+ ])
161
+ let rightInset: CGFloat = 4.0
162
+ let x = origin.x + referenceRect.minX - rightInset - ceil(markerSize.width)
163
+ let y = origin.y + baselineY - markerFont.ascender
164
+ return CGPoint(x: x, y: y)
165
+ }
166
+
167
+ static func markerBaselineOffset(
168
+ for listContext: [String: Any],
169
+ baseFont: UIFont,
170
+ markerFont: UIFont
171
+ ) -> CGFloat {
172
+ let ordered = (listContext["ordered"] as? NSNumber)?.boolValue ?? false
173
+ guard !ordered else { return 0 }
174
+
175
+ let targetMidline = (baseFont.xHeight > 0 ? baseFont.xHeight : baseFont.capHeight) / 2.0
176
+ let glyphMidline = unorderedBulletGlyphMidline(for: markerFont)
177
+ return targetMidline - glyphMidline
178
+ }
179
+
180
+ static func unorderedBulletDrawingRect(
181
+ usedRect: CGRect,
182
+ lineFragmentRect: CGRect,
183
+ markerWidth: CGFloat,
184
+ baselineY: CGFloat,
185
+ baseFont: UIFont,
186
+ markerScale: CGFloat,
187
+ origin: CGPoint
188
+ ) -> CGRect {
189
+ let markerFont = baseFont.withSize(baseFont.pointSize * markerScale)
190
+ let bulletBounds = unorderedBulletGlyphBounds(for: markerFont)
191
+ let bulletDiameter = max(max(bulletBounds.width, bulletBounds.height), 1)
192
+ let targetCenterAboveBaseline = (baseFont.xHeight > 0 ? baseFont.xHeight : baseFont.capHeight) / 2.0
193
+ let centerY = baselineY - targetCenterAboveBaseline
194
+ let referenceRect = usedRect.height > 0 ? usedRect : lineFragmentRect
195
+ let rightInset = LayoutConstants.listMarkerTextGap
196
+ let x = origin.x + referenceRect.minX - rightInset - bulletDiameter
197
+ let y = origin.y + centerY - (bulletDiameter / 2.0)
198
+
199
+ return CGRect(
200
+ x: x,
201
+ y: y,
202
+ width: bulletDiameter,
203
+ height: bulletDiameter
204
+ )
205
+ }
206
+
207
+ static func isParagraphStartCreatedByHardBreak(
208
+ _ paragraphStart: Int,
209
+ in textStorage: NSTextStorage
210
+ ) -> Bool {
211
+ guard paragraphStart > 0, paragraphStart <= textStorage.length else { return false }
212
+ let previousVoidType = textStorage.attribute(
213
+ RenderBridgeAttributes.voidNodeType,
214
+ at: paragraphStart - 1,
215
+ effectiveRange: nil
216
+ ) as? String
217
+ return previousVoidType == "hardBreak"
218
+ }
219
+
220
+ private func markerFont(
221
+ for listContext: [String: Any],
222
+ baseFont: UIFont,
223
+ markerScale: CGFloat
224
+ ) -> UIFont {
225
+ let ordered = (listContext["ordered"] as? NSNumber)?.boolValue ?? false
226
+ if ordered {
227
+ return baseFont
228
+ }
229
+ return baseFont.withSize(baseFont.pointSize * markerScale)
230
+ }
231
+
232
+ private static func unorderedBulletGlyphBounds(for font: UIFont) -> CGRect {
233
+ let ctFont = font as CTFont
234
+ let bullet = UniChar(0x2022)
235
+ var glyph = CGGlyph()
236
+ guard CTFontGetGlyphsForCharacters(ctFont, [bullet], &glyph, 1) else {
237
+ let fallbackDiameter = max(font.pointSize * 0.28, 1)
238
+ return CGRect(x: 0, y: 0, width: fallbackDiameter, height: fallbackDiameter)
239
+ }
240
+
241
+ var boundingRect = CGRect.zero
242
+ CTFontGetBoundingRectsForGlyphs(ctFont, .default, [glyph], &boundingRect, 1)
243
+ if boundingRect.isNull || boundingRect.isEmpty {
244
+ let fallbackDiameter = max(font.pointSize * 0.28, 1)
245
+ return CGRect(x: 0, y: 0, width: fallbackDiameter, height: fallbackDiameter)
246
+ }
247
+
248
+ return boundingRect
249
+ }
250
+
251
+ private static func unorderedBulletGlyphMidline(for font: UIFont) -> CGFloat {
252
+ unorderedBulletGlyphBounds(for: font).midY
253
+ }
254
+ }
@@ -0,0 +1,372 @@
1
+ import UIKit
2
+
3
+ struct EditorTextStyle {
4
+ var fontFamily: String?
5
+ var fontSize: CGFloat?
6
+ var fontWeight: String?
7
+ var fontStyle: String?
8
+ var color: UIColor?
9
+ var lineHeight: CGFloat?
10
+ var spacingAfter: CGFloat?
11
+
12
+ init(
13
+ fontFamily: String? = nil,
14
+ fontSize: CGFloat? = nil,
15
+ fontWeight: String? = nil,
16
+ fontStyle: String? = nil,
17
+ color: UIColor? = nil,
18
+ lineHeight: CGFloat? = nil,
19
+ spacingAfter: CGFloat? = nil
20
+ ) {
21
+ self.fontFamily = fontFamily
22
+ self.fontSize = fontSize
23
+ self.fontWeight = fontWeight
24
+ self.fontStyle = fontStyle
25
+ self.color = color
26
+ self.lineHeight = lineHeight
27
+ self.spacingAfter = spacingAfter
28
+ }
29
+
30
+ init(dictionary: [String: Any]) {
31
+ fontFamily = dictionary["fontFamily"] as? String
32
+ fontSize = EditorTheme.cgFloat(dictionary["fontSize"])
33
+ fontWeight = dictionary["fontWeight"] as? String
34
+ fontStyle = dictionary["fontStyle"] as? String
35
+ color = EditorTheme.color(from: dictionary["color"])
36
+ lineHeight = EditorTheme.cgFloat(dictionary["lineHeight"])
37
+ spacingAfter = EditorTheme.cgFloat(dictionary["spacingAfter"])
38
+ }
39
+
40
+ func merged(with override: EditorTextStyle?) -> EditorTextStyle {
41
+ guard let override else { return self }
42
+ return EditorTextStyle(
43
+ fontFamily: override.fontFamily ?? fontFamily,
44
+ fontSize: override.fontSize ?? fontSize,
45
+ fontWeight: override.fontWeight ?? fontWeight,
46
+ fontStyle: override.fontStyle ?? fontStyle,
47
+ color: override.color ?? color,
48
+ lineHeight: override.lineHeight ?? lineHeight,
49
+ spacingAfter: override.spacingAfter ?? spacingAfter
50
+ )
51
+ }
52
+
53
+ func resolvedFont(fallback: UIFont) -> UIFont {
54
+ let size = fontSize ?? fallback.pointSize
55
+ var font = fallback.withSize(size)
56
+
57
+ if let fontFamily,
58
+ let familyFont = UIFont(name: fontFamily, size: size) {
59
+ font = familyFont
60
+ } else if let fontWeight {
61
+ font = UIFont.systemFont(ofSize: size, weight: EditorTheme.fontWeight(from: fontWeight))
62
+ }
63
+
64
+ var traits = font.fontDescriptor.symbolicTraits
65
+ if EditorTheme.shouldApplyBoldTrait(fontWeight) {
66
+ traits.insert(.traitBold)
67
+ }
68
+ if fontStyle == "italic" {
69
+ traits.insert(.traitItalic)
70
+ }
71
+
72
+ if traits != font.fontDescriptor.symbolicTraits,
73
+ let descriptor = font.fontDescriptor.withSymbolicTraits(traits) {
74
+ font = UIFont(descriptor: descriptor, size: size)
75
+ }
76
+
77
+ return font
78
+ }
79
+ }
80
+
81
+ struct EditorListTheme {
82
+ var indent: CGFloat?
83
+ var itemSpacing: CGFloat?
84
+ var markerColor: UIColor?
85
+ var markerScale: CGFloat?
86
+
87
+ init(dictionary: [String: Any]) {
88
+ indent = EditorTheme.cgFloat(dictionary["indent"])
89
+ itemSpacing = EditorTheme.cgFloat(dictionary["itemSpacing"])
90
+ markerColor = EditorTheme.color(from: dictionary["markerColor"])
91
+ markerScale = EditorTheme.cgFloat(dictionary["markerScale"])
92
+ }
93
+ }
94
+
95
+ struct EditorHorizontalRuleTheme {
96
+ var color: UIColor?
97
+ var thickness: CGFloat?
98
+ var verticalMargin: CGFloat?
99
+
100
+ init(dictionary: [String: Any]) {
101
+ color = EditorTheme.color(from: dictionary["color"])
102
+ thickness = EditorTheme.cgFloat(dictionary["thickness"])
103
+ verticalMargin = EditorTheme.cgFloat(dictionary["verticalMargin"])
104
+ }
105
+ }
106
+
107
+ struct EditorMentionTheme {
108
+ var textColor: UIColor?
109
+ var backgroundColor: UIColor?
110
+ var borderColor: UIColor?
111
+ var borderWidth: CGFloat?
112
+ var borderRadius: CGFloat?
113
+ var fontWeight: String?
114
+ var popoverBackgroundColor: UIColor?
115
+ var popoverBorderColor: UIColor?
116
+ var popoverBorderWidth: CGFloat?
117
+ var popoverBorderRadius: CGFloat?
118
+ var popoverShadowColor: UIColor?
119
+ var optionTextColor: UIColor?
120
+ var optionSecondaryTextColor: UIColor?
121
+ var optionHighlightedBackgroundColor: UIColor?
122
+ var optionHighlightedTextColor: UIColor?
123
+
124
+ init(dictionary: [String: Any]) {
125
+ textColor = EditorTheme.color(from: dictionary["textColor"])
126
+ backgroundColor = EditorTheme.color(from: dictionary["backgroundColor"])
127
+ borderColor = EditorTheme.color(from: dictionary["borderColor"])
128
+ borderWidth = EditorTheme.cgFloat(dictionary["borderWidth"])
129
+ borderRadius = EditorTheme.cgFloat(dictionary["borderRadius"])
130
+ fontWeight = dictionary["fontWeight"] as? String
131
+ popoverBackgroundColor = EditorTheme.color(from: dictionary["popoverBackgroundColor"])
132
+ popoverBorderColor = EditorTheme.color(from: dictionary["popoverBorderColor"])
133
+ popoverBorderWidth = EditorTheme.cgFloat(dictionary["popoverBorderWidth"])
134
+ popoverBorderRadius = EditorTheme.cgFloat(dictionary["popoverBorderRadius"])
135
+ popoverShadowColor = EditorTheme.color(from: dictionary["popoverShadowColor"])
136
+ optionTextColor = EditorTheme.color(from: dictionary["optionTextColor"])
137
+ optionSecondaryTextColor = EditorTheme.color(from: dictionary["optionSecondaryTextColor"])
138
+ optionHighlightedBackgroundColor = EditorTheme.color(from: dictionary["optionHighlightedBackgroundColor"])
139
+ optionHighlightedTextColor = EditorTheme.color(from: dictionary["optionHighlightedTextColor"])
140
+ }
141
+ }
142
+
143
+ struct EditorToolbarTheme {
144
+ var backgroundColor: UIColor?
145
+ var borderColor: UIColor?
146
+ var borderWidth: CGFloat?
147
+ var borderRadius: CGFloat?
148
+ var keyboardOffset: CGFloat?
149
+ var horizontalInset: CGFloat?
150
+ var separatorColor: UIColor?
151
+ var buttonColor: UIColor?
152
+ var buttonActiveColor: UIColor?
153
+ var buttonDisabledColor: UIColor?
154
+ var buttonActiveBackgroundColor: UIColor?
155
+ var buttonBorderRadius: CGFloat?
156
+
157
+ init(dictionary: [String: Any]) {
158
+ backgroundColor = EditorTheme.color(from: dictionary["backgroundColor"])
159
+ borderColor = EditorTheme.color(from: dictionary["borderColor"])
160
+ borderWidth = EditorTheme.cgFloat(dictionary["borderWidth"])
161
+ borderRadius = EditorTheme.cgFloat(dictionary["borderRadius"])
162
+ keyboardOffset = EditorTheme.cgFloat(dictionary["keyboardOffset"])
163
+ horizontalInset = EditorTheme.cgFloat(dictionary["horizontalInset"])
164
+ separatorColor = EditorTheme.color(from: dictionary["separatorColor"])
165
+ buttonColor = EditorTheme.color(from: dictionary["buttonColor"])
166
+ buttonActiveColor = EditorTheme.color(from: dictionary["buttonActiveColor"])
167
+ buttonDisabledColor = EditorTheme.color(from: dictionary["buttonDisabledColor"])
168
+ buttonActiveBackgroundColor = EditorTheme.color(from: dictionary["buttonActiveBackgroundColor"])
169
+ buttonBorderRadius = EditorTheme.cgFloat(dictionary["buttonBorderRadius"])
170
+ }
171
+ }
172
+
173
+ struct EditorContentInsets {
174
+ var top: CGFloat?
175
+ var right: CGFloat?
176
+ var bottom: CGFloat?
177
+ var left: CGFloat?
178
+
179
+ init(dictionary: [String: Any]) {
180
+ top = EditorTheme.cgFloat(dictionary["top"])
181
+ right = EditorTheme.cgFloat(dictionary["right"])
182
+ bottom = EditorTheme.cgFloat(dictionary["bottom"])
183
+ left = EditorTheme.cgFloat(dictionary["left"])
184
+ }
185
+ }
186
+
187
+ struct EditorTheme {
188
+ var text: EditorTextStyle?
189
+ var paragraph: EditorTextStyle?
190
+ var headings: [String: EditorTextStyle] = [:]
191
+ var list: EditorListTheme?
192
+ var horizontalRule: EditorHorizontalRuleTheme?
193
+ var mentions: EditorMentionTheme?
194
+ var toolbar: EditorToolbarTheme?
195
+ var backgroundColor: UIColor?
196
+ var borderRadius: CGFloat?
197
+ var contentInsets: EditorContentInsets?
198
+
199
+ static func from(json: String?) -> EditorTheme? {
200
+ guard let json, !json.isEmpty,
201
+ let data = json.data(using: .utf8),
202
+ let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
203
+ else {
204
+ return nil
205
+ }
206
+ return EditorTheme(dictionary: raw)
207
+ }
208
+
209
+ init(dictionary: [String: Any]) {
210
+ if let text = dictionary["text"] as? [String: Any] {
211
+ self.text = EditorTextStyle(dictionary: text)
212
+ }
213
+ if let paragraph = dictionary["paragraph"] as? [String: Any] {
214
+ self.paragraph = EditorTextStyle(dictionary: paragraph)
215
+ }
216
+ if let headings = dictionary["headings"] as? [String: Any] {
217
+ for level in ["h1", "h2", "h3", "h4", "h5", "h6"] {
218
+ if let style = headings[level] as? [String: Any] {
219
+ self.headings[level] = EditorTextStyle(dictionary: style)
220
+ }
221
+ }
222
+ }
223
+ if let list = dictionary["list"] as? [String: Any] {
224
+ self.list = EditorListTheme(dictionary: list)
225
+ }
226
+ if let horizontalRule = dictionary["horizontalRule"] as? [String: Any] {
227
+ self.horizontalRule = EditorHorizontalRuleTheme(dictionary: horizontalRule)
228
+ }
229
+ if let mentions = dictionary["mentions"] as? [String: Any] {
230
+ self.mentions = EditorMentionTheme(dictionary: mentions)
231
+ }
232
+ if let toolbar = dictionary["toolbar"] as? [String: Any] {
233
+ self.toolbar = EditorToolbarTheme(dictionary: toolbar)
234
+ }
235
+ backgroundColor = EditorTheme.color(from: dictionary["backgroundColor"])
236
+ borderRadius = EditorTheme.cgFloat(dictionary["borderRadius"])
237
+ if let contentInsets = dictionary["contentInsets"] as? [String: Any] {
238
+ self.contentInsets = EditorContentInsets(dictionary: contentInsets)
239
+ }
240
+ }
241
+
242
+ func effectiveTextStyle(for nodeType: String) -> EditorTextStyle {
243
+ var style = text ?? EditorTextStyle()
244
+ if nodeType == "paragraph" {
245
+ style = style.merged(with: paragraph)
246
+ if paragraph?.lineHeight == nil {
247
+ style.lineHeight = nil
248
+ }
249
+ }
250
+ style = style.merged(with: headings[nodeType])
251
+ return style
252
+ }
253
+
254
+ static func cgFloat(_ value: Any?) -> CGFloat? {
255
+ guard let number = value as? NSNumber else { return nil }
256
+ return CGFloat(truncating: number)
257
+ }
258
+
259
+ static func fontWeight(from value: String) -> UIFont.Weight {
260
+ switch value {
261
+ case "100": return .ultraLight
262
+ case "200": return .thin
263
+ case "300": return .light
264
+ case "500": return .medium
265
+ case "600": return .semibold
266
+ case "700", "bold": return .bold
267
+ case "800": return .heavy
268
+ case "900": return .black
269
+ default: return .regular
270
+ }
271
+ }
272
+
273
+ static func shouldApplyBoldTrait(_ value: String?) -> Bool {
274
+ guard let value else { return false }
275
+ return value == "bold" || Int(value).map { $0 >= 600 } == true
276
+ }
277
+
278
+ static func color(from value: Any?) -> UIColor? {
279
+ guard let raw = value as? String else { return nil }
280
+ let string = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
281
+
282
+ if let hexColor = colorFromHex(string) {
283
+ return hexColor
284
+ }
285
+ if let rgbColor = colorFromRGBFunction(string) {
286
+ return rgbColor
287
+ }
288
+
289
+ switch string {
290
+ case "black": return .black
291
+ case "white": return .white
292
+ case "red": return .red
293
+ case "green": return .green
294
+ case "blue": return .blue
295
+ case "gray", "grey": return .gray
296
+ case "clear", "transparent": return .clear
297
+ default: return nil
298
+ }
299
+ }
300
+
301
+ private static func colorFromHex(_ string: String) -> UIColor? {
302
+ guard string.hasPrefix("#") else { return nil }
303
+ let hex = String(string.dropFirst())
304
+
305
+ switch hex.count {
306
+ case 3:
307
+ let chars = Array(hex)
308
+ return UIColor(
309
+ red: component(String(repeating: String(chars[0]), count: 2)),
310
+ green: component(String(repeating: String(chars[1]), count: 2)),
311
+ blue: component(String(repeating: String(chars[2]), count: 2)),
312
+ alpha: 1
313
+ )
314
+ case 4:
315
+ let chars = Array(hex)
316
+ return UIColor(
317
+ red: component(String(repeating: String(chars[0]), count: 2)),
318
+ green: component(String(repeating: String(chars[1]), count: 2)),
319
+ blue: component(String(repeating: String(chars[2]), count: 2)),
320
+ alpha: component(String(repeating: String(chars[3]), count: 2))
321
+ )
322
+ case 6:
323
+ return UIColor(
324
+ red: component(String(hex.prefix(2))),
325
+ green: component(String(hex.dropFirst(2).prefix(2))),
326
+ blue: component(String(hex.dropFirst(4).prefix(2))),
327
+ alpha: 1
328
+ )
329
+ case 8:
330
+ return UIColor(
331
+ red: component(String(hex.prefix(2))),
332
+ green: component(String(hex.dropFirst(2).prefix(2))),
333
+ blue: component(String(hex.dropFirst(4).prefix(2))),
334
+ alpha: component(String(hex.dropFirst(6).prefix(2)))
335
+ )
336
+ default:
337
+ return nil
338
+ }
339
+ }
340
+
341
+ private static func colorFromRGBFunction(_ string: String) -> UIColor? {
342
+ let isRGBA = string.hasPrefix("rgba(") && string.hasSuffix(")")
343
+ let isRGB = string.hasPrefix("rgb(") && string.hasSuffix(")")
344
+ guard isRGBA || isRGB else { return nil }
345
+
346
+ let start = string.index(string.startIndex, offsetBy: isRGBA ? 5 : 4)
347
+ let end = string.index(before: string.endIndex)
348
+ let parts = string[start..<end]
349
+ .split(separator: ",")
350
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
351
+
352
+ guard parts.count == (isRGBA ? 4 : 3),
353
+ let red = Double(parts[0]),
354
+ let green = Double(parts[1]),
355
+ let blue = Double(parts[2])
356
+ else {
357
+ return nil
358
+ }
359
+
360
+ let alpha = isRGBA ? (Double(parts[3]) ?? 1) : 1
361
+ return UIColor(
362
+ red: red / 255,
363
+ green: green / 255,
364
+ blue: blue / 255,
365
+ alpha: alpha
366
+ )
367
+ }
368
+
369
+ private static func component(_ hex: String) -> CGFloat {
370
+ CGFloat(Int(hex, radix: 16) ?? 0) / 255
371
+ }
372
+ }