react-native-markdown-native 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/MarkdownNative.podspec +19 -0
- package/README.md +109 -0
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/executionHistory/executionHistory.lock +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build.gradle +36 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/markdownnative/MarkdownPackage.java +25 -0
- package/android/src/main/java/com/markdownnative/MarkdownView.java +516 -0
- package/android/src/main/java/com/markdownnative/MarkdownViewManager.java +107 -0
- package/ios/MarkdownView.swift +771 -0
- package/ios/MarkdownViewManager.m +27 -0
- package/ios/MarkdownViewManager.swift +20 -0
- package/lib/commonjs/index.js +97 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +154 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/index.d.ts +26 -0
- package/lib/index.js +97 -0
- package/lib/module/index.js +80 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/types.js +153 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/index.d.ts +30 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +80 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/package.json +92 -0
- package/react-native.config.js +11 -0
- package/src/index.tsx +117 -0
- package/src/types.ts +205 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import React
|
|
3
|
+
|
|
4
|
+
@available(iOS 15.0, *)
|
|
5
|
+
class MarkdownView: UITextView {
|
|
6
|
+
|
|
7
|
+
@objc var onNativeSizeChange: RCTDirectEventBlock?
|
|
8
|
+
|
|
9
|
+
@objc var markdownText: String = "" {
|
|
10
|
+
didSet { renderContent() }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@objc var markdownFontSize: CGFloat = 16.0 {
|
|
14
|
+
didSet { renderContent() }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@objc var markdownColor: String = "" {
|
|
18
|
+
didSet { renderContent() }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@objc var markdownFontFamily: String = "" {
|
|
22
|
+
didSet { renderContent() }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@objc var markdownSelectionColor: String = "" {
|
|
26
|
+
didSet { updateSelectionColor() }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@objc var markdownScrollEnabled: Bool = false {
|
|
30
|
+
didSet { self.isScrollEnabled = markdownScrollEnabled }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@objc var markdownSelectable: Bool = true {
|
|
34
|
+
didSet { self.isSelectable = markdownSelectable }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Customization props
|
|
38
|
+
@objc var markdownLinkColor: String = "" {
|
|
39
|
+
didSet {
|
|
40
|
+
renderContent()
|
|
41
|
+
updateLinkAttributes()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@objc var markdownLineSpacing: CGFloat = 0.0 {
|
|
46
|
+
didSet { renderContent() }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@objc var markdownParagraphSpacing: CGFloat = 0.0 {
|
|
50
|
+
didSet { renderContent() }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@objc var markdownBulletIndent: CGFloat = 0.0 {
|
|
54
|
+
didSet { renderContent() }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// List styling props
|
|
58
|
+
@objc var markdownListLeftInset: CGFloat = 0.0 {
|
|
59
|
+
didSet { renderContent() }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@objc var markdownListSpacingBefore: CGFloat = 0.0 {
|
|
63
|
+
didSet { renderContent() }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@objc var markdownListSpacingAfter: CGFloat = 0.0 {
|
|
67
|
+
didSet { renderContent() }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// New rules-based styling system
|
|
71
|
+
@objc var markdownRulesJson: String = "" {
|
|
72
|
+
didSet { parseAndApplyRules() }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Parsed rules storage
|
|
76
|
+
private var parsedRules: [String: [String: Any]] = [:]
|
|
77
|
+
|
|
78
|
+
// MARK: - Initialization
|
|
79
|
+
|
|
80
|
+
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
81
|
+
super.init(frame: frame, textContainer: textContainer)
|
|
82
|
+
setupView()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
required init?(coder: NSCoder) {
|
|
86
|
+
super.init(coder: coder)
|
|
87
|
+
setupView()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private func setupView() {
|
|
91
|
+
self.backgroundColor = .clear
|
|
92
|
+
self.isEditable = false
|
|
93
|
+
self.isScrollEnabled = false
|
|
94
|
+
self.isSelectable = true
|
|
95
|
+
self.textContainerInset = .zero
|
|
96
|
+
self.textContainer.lineFragmentPadding = 0
|
|
97
|
+
self.clipsToBounds = false
|
|
98
|
+
|
|
99
|
+
self.clipsToBounds = false
|
|
100
|
+
|
|
101
|
+
self.dataDetectorTypes = [.link]
|
|
102
|
+
self.isUserInteractionEnabled = true
|
|
103
|
+
updateLinkAttributes()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private func updateLinkAttributes() {
|
|
107
|
+
let linkColor = resolveColor(markdownLinkColor) ?? UIColor.systemBlue
|
|
108
|
+
self.linkTextAttributes = [
|
|
109
|
+
.foregroundColor: linkColor,
|
|
110
|
+
.underlineStyle: NSUnderlineStyle.single.rawValue
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// MARK: - Rendering
|
|
115
|
+
|
|
116
|
+
private func renderContent() {
|
|
117
|
+
guard !markdownText.isEmpty else {
|
|
118
|
+
self.attributedText = nil
|
|
119
|
+
notifySizeChange()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
let textColor = resolveColor(markdownColor) ?? UIColor.black
|
|
126
|
+
let fontSize = markdownFontSize > 0 ? markdownFontSize : 16.0
|
|
127
|
+
let font = resolveFont(markdownFontFamily, size: fontSize)
|
|
128
|
+
let boldFont = UIFont.boldSystemFont(ofSize: fontSize)
|
|
129
|
+
let italicFont = UIFont.italicSystemFont(ofSize: fontSize)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
let attributedString = parseMarkdownManually(
|
|
134
|
+
markdownText,
|
|
135
|
+
font: font,
|
|
136
|
+
boldFont: boldFont,
|
|
137
|
+
italicFont: italicFont,
|
|
138
|
+
color: textColor,
|
|
139
|
+
fontSize: fontSize
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
self.attributedText = attributedString
|
|
143
|
+
self.textColor = textColor
|
|
144
|
+
|
|
145
|
+
updateSelectionColor()
|
|
146
|
+
|
|
147
|
+
updateSelectionColor()
|
|
148
|
+
|
|
149
|
+
self.setNeedsLayout()
|
|
150
|
+
self.layoutIfNeeded()
|
|
151
|
+
notifySizeChange()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func parseMarkdownManually(_ text: String, font: UIFont, boldFont: UIFont, italicFont: UIFont, color: UIColor, fontSize: CGFloat) -> NSAttributedString {
|
|
155
|
+
let result = NSMutableAttributedString()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
let lines = text.components(separatedBy: "\n")
|
|
160
|
+
|
|
161
|
+
for (index, line) in lines.enumerated() {
|
|
162
|
+
let isEmpty = line.trimmingCharacters(in: .whitespaces).isEmpty
|
|
163
|
+
let isListItem = isListLine(line)
|
|
164
|
+
|
|
165
|
+
let isNextNonEmptyLineList = isNextNonEmptyList(lines: lines, currentIndex: index)
|
|
166
|
+
|
|
167
|
+
if isEmpty {
|
|
168
|
+
if index < lines.count - 1 {
|
|
169
|
+
result.append(NSAttributedString(string: "\n"))
|
|
170
|
+
}
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
var spacingAfter: CGFloat = markdownParagraphSpacing
|
|
177
|
+
|
|
178
|
+
if !isListItem && isNextNonEmptyLineList {
|
|
179
|
+
spacingAfter = markdownListSpacingBefore
|
|
180
|
+
} else if isListItem && !isNextNonEmptyLineList {
|
|
181
|
+
spacingAfter = markdownListSpacingAfter
|
|
182
|
+
} else if isListItem && isNextNonEmptyLineList {
|
|
183
|
+
spacingAfter = markdownParagraphSpacing
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let processedLine = processLine(
|
|
187
|
+
line,
|
|
188
|
+
font: font,
|
|
189
|
+
boldFont: boldFont,
|
|
190
|
+
italicFont: italicFont,
|
|
191
|
+
color: color,
|
|
192
|
+
fontSize: fontSize,
|
|
193
|
+
spacingBefore: 0,
|
|
194
|
+
spacingAfter: spacingAfter
|
|
195
|
+
)
|
|
196
|
+
result.append(processedLine)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if index < lines.count - 1 {
|
|
201
|
+
result.append(NSAttributedString(string: "\n"))
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return result
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private func isNextNonEmptyList(lines: [String], currentIndex: Int) -> Bool {
|
|
209
|
+
for i in (currentIndex + 1)..<lines.count {
|
|
210
|
+
let line = lines[i].trimmingCharacters(in: .whitespaces)
|
|
211
|
+
if !line.isEmpty {
|
|
212
|
+
return isListLine(lines[i])
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private func isPrevNonEmptyList(lines: [String], currentIndex: Int) -> Bool {
|
|
219
|
+
for i in stride(from: currentIndex - 1, through: 0, by: -1) {
|
|
220
|
+
let line = lines[i].trimmingCharacters(in: .whitespaces)
|
|
221
|
+
if !line.isEmpty {
|
|
222
|
+
return isListLine(lines[i])
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private func isListLine(_ line: String) -> Bool {
|
|
229
|
+
|
|
230
|
+
if line.range(of: "^\\d+\\.\\s+", options: .regularExpression) != nil {
|
|
231
|
+
return true
|
|
232
|
+
}
|
|
233
|
+
if line.range(of: "^[-*+]\\s+", options: .regularExpression) != nil {
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private func processLine(_ line: String, font: UIFont, boldFont: UIFont, italicFont: UIFont, color: UIColor, fontSize: CGFloat, spacingBefore: CGFloat, spacingAfter: CGFloat) -> NSAttributedString {
|
|
240
|
+
var text = line
|
|
241
|
+
|
|
242
|
+
let baseParagraphStyle = NSMutableParagraphStyle()
|
|
243
|
+
baseParagraphStyle.lineSpacing = markdownLineSpacing
|
|
244
|
+
baseParagraphStyle.paragraphSpacing = spacingAfter
|
|
245
|
+
baseParagraphStyle.paragraphSpacingBefore = spacingBefore
|
|
246
|
+
|
|
247
|
+
var attributes: [NSAttributedString.Key: Any] = [
|
|
248
|
+
.font: font,
|
|
249
|
+
.foregroundColor: color,
|
|
250
|
+
.paragraphStyle: baseParagraphStyle
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if let headerMatch = text.range(of: "^#{1,6}\\s+", options: .regularExpression) {
|
|
255
|
+
let headerLevel = text[headerMatch].filter { $0 == "#" }.count
|
|
256
|
+
text = String(text[headerMatch.upperBound...])
|
|
257
|
+
|
|
258
|
+
// Determine element type based on header level
|
|
259
|
+
let headerType: String
|
|
260
|
+
switch headerLevel {
|
|
261
|
+
case 1: headerType = "heading1"
|
|
262
|
+
case 2: headerType = "heading2"
|
|
263
|
+
case 3: headerType = "heading3"
|
|
264
|
+
case 4: headerType = "heading4"
|
|
265
|
+
case 5: headerType = "heading5"
|
|
266
|
+
case 6: headerType = "heading6"
|
|
267
|
+
default: headerType = "heading6"
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Apply rules if available, otherwise use defaults
|
|
271
|
+
let headerFont = applyRulesToFont(headerType, baseSize: fontSize * (headerLevel == 1 ? 2.0 : headerLevel == 2 ? 1.5 : headerLevel == 3 ? 1.25 : headerLevel == 4 ? 1.1 : 1.0))
|
|
272
|
+
let headerColor = applyRulesToColor(headerType, defaultColor: color)
|
|
273
|
+
|
|
274
|
+
let headerParagraph = NSMutableParagraphStyle()
|
|
275
|
+
headerParagraph.paragraphSpacingBefore = getRuleValue(headerType, "marginTop", defaultValue: 8)
|
|
276
|
+
headerParagraph.paragraphSpacing = getRuleValue(headerType, "marginBottom", defaultValue: 4)
|
|
277
|
+
headerParagraph.lineSpacing = markdownLineSpacing
|
|
278
|
+
|
|
279
|
+
attributes[.font] = headerFont
|
|
280
|
+
attributes[.foregroundColor] = headerColor
|
|
281
|
+
attributes[.paragraphStyle] = headerParagraph
|
|
282
|
+
|
|
283
|
+
return applyInlineFormatting(to: text, baseAttributes: attributes, boldFont: headerFont, italicFont: UIFont.italicSystemFont(ofSize: headerFont.pointSize))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Blockquote handling (> text)
|
|
287
|
+
if text.hasPrefix("> ") || text.hasPrefix(">") {
|
|
288
|
+
let content = text.hasPrefix("> ") ? String(text.dropFirst(2)) : String(text.dropFirst(1))
|
|
289
|
+
|
|
290
|
+
let quoteFont = applyRulesToFont("blockquote", baseSize: fontSize)
|
|
291
|
+
let quoteColor = applyRulesToColor("blockquote", defaultColor: color)
|
|
292
|
+
|
|
293
|
+
let quoteParagraph = NSMutableParagraphStyle()
|
|
294
|
+
quoteParagraph.paragraphSpacingBefore = getRuleValue("blockquote", "marginTop", defaultValue: 8)
|
|
295
|
+
quoteParagraph.paragraphSpacing = getRuleValue("blockquote", "marginBottom", defaultValue: 8)
|
|
296
|
+
quoteParagraph.lineSpacing = markdownLineSpacing
|
|
297
|
+
|
|
298
|
+
// Apply left border effect with padding
|
|
299
|
+
let leftInset: CGFloat = getRuleValue("blockquote", "paddingLeft", defaultValue: 12)
|
|
300
|
+
let marginLeft: CGFloat = getRuleValue("blockquote", "marginLeft", defaultValue: 16)
|
|
301
|
+
quoteParagraph.firstLineHeadIndent = marginLeft + leftInset
|
|
302
|
+
quoteParagraph.headIndent = marginLeft + leftInset
|
|
303
|
+
|
|
304
|
+
attributes[.font] = quoteFont
|
|
305
|
+
attributes[.foregroundColor] = quoteColor
|
|
306
|
+
attributes[.paragraphStyle] = quoteParagraph
|
|
307
|
+
|
|
308
|
+
// Background color for blockquote
|
|
309
|
+
if let bgColorStr: String = getRuleValue("blockquote", "backgroundColor", defaultValue: nil),
|
|
310
|
+
let bgColor = resolveColor(bgColorStr) {
|
|
311
|
+
attributes[.backgroundColor] = bgColor
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return applyInlineFormatting(to: content, baseAttributes: attributes, boldFont: boldFont, italicFont: italicFont)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Horizontal rule handling (---, ***, ___)
|
|
318
|
+
if text.range(of: "^[-*_]{3,}$", options: .regularExpression) != nil {
|
|
319
|
+
let hrParagraph = NSMutableParagraphStyle()
|
|
320
|
+
hrParagraph.paragraphSpacingBefore = getRuleValue("hr", "marginTop", defaultValue: 16)
|
|
321
|
+
hrParagraph.paragraphSpacing = getRuleValue("hr", "marginBottom", defaultValue: 16)
|
|
322
|
+
|
|
323
|
+
attributes[.paragraphStyle] = hrParagraph
|
|
324
|
+
|
|
325
|
+
// Create a visual line
|
|
326
|
+
let lineText = "─────────────────────────────────────"
|
|
327
|
+
let lineColor = getRuleValue("hr", "borderBottomColor", defaultValue: "#CCCCCC")
|
|
328
|
+
if let color = resolveColor(lineColor) {
|
|
329
|
+
attributes[.foregroundColor] = color
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return NSAttributedString(string: lineText, attributes: attributes)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if let listMatch = text.range(of: "^\\d+\\.\\s+", options: .regularExpression) {
|
|
337
|
+
let prefix = String(text[listMatch])
|
|
338
|
+
let content = String(text[listMatch.upperBound...])
|
|
339
|
+
|
|
340
|
+
// Apply rules for ordered list items
|
|
341
|
+
let itemFont = applyRulesToFont("orderedListItem", baseSize: fontSize)
|
|
342
|
+
let itemColor = applyRulesToColor("orderedListItem", defaultColor: color)
|
|
343
|
+
|
|
344
|
+
let prefixSize = (prefix as NSString).size(withAttributes: [.font: itemFont])
|
|
345
|
+
let bulletIndent = getRuleValue("orderedListItem", "bulletIndent", defaultValue: markdownBulletIndent > 0 ? markdownBulletIndent : prefixSize.width)
|
|
346
|
+
let leftInset = getRuleValue("orderedListItem", "listLeftInset", defaultValue: markdownListLeftInset)
|
|
347
|
+
|
|
348
|
+
let listParagraph = NSMutableParagraphStyle()
|
|
349
|
+
listParagraph.headIndent = leftInset + bulletIndent
|
|
350
|
+
listParagraph.firstLineHeadIndent = leftInset
|
|
351
|
+
listParagraph.paragraphSpacing = getRuleValue("orderedListItem", "marginBottom", defaultValue: spacingAfter)
|
|
352
|
+
listParagraph.paragraphSpacingBefore = spacingBefore
|
|
353
|
+
listParagraph.lineSpacing = getRuleValue("orderedListItem", "lineHeight", defaultValue: markdownLineSpacing)
|
|
354
|
+
|
|
355
|
+
attributes[.font] = itemFont
|
|
356
|
+
attributes[.foregroundColor] = itemColor
|
|
357
|
+
attributes[.paragraphStyle] = listParagraph
|
|
358
|
+
|
|
359
|
+
let result = NSMutableAttributedString(string: prefix, attributes: attributes)
|
|
360
|
+
result.append(applyInlineFormatting(to: content, baseAttributes: attributes, boldFont: boldFont, italicFont: italicFont))
|
|
361
|
+
return result
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if let bulletMatch = text.range(of: "^[-*+]\\s+", options: .regularExpression) {
|
|
367
|
+
let content = String(text[bulletMatch.upperBound...])
|
|
368
|
+
|
|
369
|
+
// Apply rules for list items
|
|
370
|
+
let itemFont = applyRulesToFont("listItem", baseSize: fontSize)
|
|
371
|
+
let itemColor = applyRulesToColor("listItem", defaultColor: color)
|
|
372
|
+
|
|
373
|
+
let bulletPrefix = "• "
|
|
374
|
+
|
|
375
|
+
let bulletSize = (bulletPrefix as NSString).size(withAttributes: [.font: itemFont])
|
|
376
|
+
let bulletIndent = getRuleValue("listItem", "bulletIndent", defaultValue: markdownBulletIndent > 0 ? markdownBulletIndent : bulletSize.width)
|
|
377
|
+
let leftInset = getRuleValue("listItem", "listLeftInset", defaultValue: markdownListLeftInset)
|
|
378
|
+
|
|
379
|
+
let bulletParagraph = NSMutableParagraphStyle()
|
|
380
|
+
bulletParagraph.headIndent = leftInset + bulletIndent
|
|
381
|
+
bulletParagraph.firstLineHeadIndent = leftInset
|
|
382
|
+
bulletParagraph.paragraphSpacing = getRuleValue("listItem", "marginBottom", defaultValue: spacingAfter)
|
|
383
|
+
bulletParagraph.paragraphSpacingBefore = spacingBefore
|
|
384
|
+
bulletParagraph.lineSpacing = getRuleValue("listItem", "lineHeight", defaultValue: markdownLineSpacing)
|
|
385
|
+
|
|
386
|
+
attributes[.font] = itemFont
|
|
387
|
+
attributes[.foregroundColor] = itemColor
|
|
388
|
+
attributes[.paragraphStyle] = bulletParagraph
|
|
389
|
+
|
|
390
|
+
let result = NSMutableAttributedString(string: bulletPrefix, attributes: attributes)
|
|
391
|
+
result.append(applyInlineFormatting(to: content, baseAttributes: attributes, boldFont: boldFont, italicFont: italicFont))
|
|
392
|
+
return result
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return applyInlineFormatting(to: text, baseAttributes: attributes, boldFont: boldFont, italicFont: italicFont)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private func applyInlineFormatting(to text: String, baseAttributes: [NSAttributedString.Key: Any], boldFont: UIFont, italicFont: UIFont) -> NSAttributedString {
|
|
399
|
+
let result = NSMutableAttributedString(string: text, attributes: baseAttributes)
|
|
400
|
+
|
|
401
|
+
// Apply link patterns first (they need special handling)
|
|
402
|
+
applyLinkPattern(to: result, baseAttributes: baseAttributes)
|
|
403
|
+
|
|
404
|
+
// Apply bold formatting using rules
|
|
405
|
+
let strongFont = applyRulesToFont("strong", baseSize: boldFont.pointSize)
|
|
406
|
+
let strongColor = applyRulesToColor("strong", defaultColor: baseAttributes[.foregroundColor] as? UIColor ?? UIColor.black)
|
|
407
|
+
applyPatternWithColor("\\*\\*(.+?)\\*\\*|__(.+?)__", to: result, font: strongFont, color: strongColor)
|
|
408
|
+
|
|
409
|
+
// Apply italic formatting using rules
|
|
410
|
+
let emFont = applyRulesToFont("em", baseSize: italicFont.pointSize)
|
|
411
|
+
let emColor = applyRulesToColor("em", defaultColor: baseAttributes[.foregroundColor] as? UIColor ?? UIColor.black)
|
|
412
|
+
applyPatternWithColor("(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", to: result, font: emFont, color: emColor)
|
|
413
|
+
|
|
414
|
+
// Apply strikethrough formatting using rules (~~text~~)
|
|
415
|
+
let strikeColor = applyRulesToColor("strikethrough", defaultColor: baseAttributes[.foregroundColor] as? UIColor ?? UIColor.black)
|
|
416
|
+
applyStrikethroughPattern("~~(.+?)~~", to: result, color: strikeColor)
|
|
417
|
+
|
|
418
|
+
// Apply inline code formatting using rules
|
|
419
|
+
let codeFont = applyRulesToFont("codeInline", baseSize: (baseAttributes[.font] as? UIFont)?.pointSize ?? 16)
|
|
420
|
+
let codeColor = applyRulesToColor("codeInline", defaultColor: UIColor.black)
|
|
421
|
+
let codeBgColor = getRuleValue("codeInline", "backgroundColor", defaultValue: "#F5F5F5")
|
|
422
|
+
applyCodePattern("`(.+?)`", to: result, font: codeFont, color: codeColor, backgroundColor: codeBgColor)
|
|
423
|
+
|
|
424
|
+
return result
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private func applyLinkPattern(to attributedString: NSMutableAttributedString, baseAttributes: [NSAttributedString.Key: Any]) {
|
|
428
|
+
|
|
429
|
+
let linkPattern = "\\[([^\\]]+)\\]\\(([^)]+)\\)"
|
|
430
|
+
guard let regex = try? NSRegularExpression(pattern: linkPattern, options: []) else { return }
|
|
431
|
+
|
|
432
|
+
let fullRange = NSRange(location: 0, length: attributedString.length)
|
|
433
|
+
let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
let linkColor = resolveColor(markdownLinkColor) ?? UIColor.systemBlue
|
|
438
|
+
|
|
439
|
+
for match in matches.reversed() {
|
|
440
|
+
guard match.numberOfRanges >= 3 else { continue }
|
|
441
|
+
|
|
442
|
+
let textRange = match.range(at: 1)
|
|
443
|
+
let urlRange = match.range(at: 2)
|
|
444
|
+
|
|
445
|
+
guard textRange.location != NSNotFound, urlRange.location != NSNotFound else { continue }
|
|
446
|
+
|
|
447
|
+
let linkText = (attributedString.string as NSString).substring(with: textRange)
|
|
448
|
+
let urlString = (attributedString.string as NSString).substring(with: urlRange)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
var linkAttributes = baseAttributes
|
|
453
|
+
linkAttributes[.foregroundColor] = linkColor
|
|
454
|
+
linkAttributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
455
|
+
|
|
456
|
+
if let url = URL(string: urlString) {
|
|
457
|
+
linkAttributes[.link] = url
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let formattedLink = NSAttributedString(string: linkText, attributes: linkAttributes)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
attributedString.replaceCharacters(in: match.range, with: formattedLink)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private func applyPattern(_ pattern: String, to attributedString: NSMutableAttributedString, attribute: NSAttributedString.Key, value: Any) {
|
|
469
|
+
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
|
|
470
|
+
|
|
471
|
+
let fullRange = NSRange(location: 0, length: attributedString.length)
|
|
472
|
+
let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
|
|
473
|
+
|
|
474
|
+
// Process in reverse to preserve ranges
|
|
475
|
+
for match in matches.reversed() {
|
|
476
|
+
// Find the actual content (first non-nil group)
|
|
477
|
+
var contentRange: NSRange?
|
|
478
|
+
for i in 1..<match.numberOfRanges {
|
|
479
|
+
let range = match.range(at: i)
|
|
480
|
+
if range.location != NSNotFound {
|
|
481
|
+
contentRange = range
|
|
482
|
+
break
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
guard let actualContentRange = contentRange else { continue }
|
|
487
|
+
|
|
488
|
+
// Get the content without markers
|
|
489
|
+
let content = (attributedString.string as NSString).substring(with: actualContentRange)
|
|
490
|
+
|
|
491
|
+
// Create new attributed string with the formatting
|
|
492
|
+
var newAttributes = attributedString.attributes(at: actualContentRange.location, effectiveRange: nil)
|
|
493
|
+
newAttributes[attribute] = value
|
|
494
|
+
|
|
495
|
+
let formattedContent = NSAttributedString(string: content, attributes: newAttributes)
|
|
496
|
+
|
|
497
|
+
// Replace the entire match (including markers) with just the formatted content
|
|
498
|
+
attributedString.replaceCharacters(in: match.range, with: formattedContent)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private func applyPatternWithColor(_ pattern: String, to attributedString: NSMutableAttributedString, font: UIFont, color: UIColor) {
|
|
503
|
+
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
|
|
504
|
+
|
|
505
|
+
let fullRange = NSRange(location: 0, length: attributedString.length)
|
|
506
|
+
let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
|
|
507
|
+
|
|
508
|
+
for match in matches.reversed() {
|
|
509
|
+
var contentRange: NSRange?
|
|
510
|
+
for i in 1..<match.numberOfRanges {
|
|
511
|
+
let range = match.range(at: i)
|
|
512
|
+
if range.location != NSNotFound {
|
|
513
|
+
contentRange = range
|
|
514
|
+
break
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
guard let actualContentRange = contentRange else { continue }
|
|
519
|
+
|
|
520
|
+
let content = (attributedString.string as NSString).substring(with: actualContentRange)
|
|
521
|
+
|
|
522
|
+
var newAttributes = attributedString.attributes(at: actualContentRange.location, effectiveRange: nil)
|
|
523
|
+
newAttributes[.font] = font
|
|
524
|
+
newAttributes[.foregroundColor] = color
|
|
525
|
+
|
|
526
|
+
let formattedContent = NSAttributedString(string: content, attributes: newAttributes)
|
|
527
|
+
attributedString.replaceCharacters(in: match.range, with: formattedContent)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private func applyCodePattern(_ pattern: String, to attributedString: NSMutableAttributedString, font: UIFont, color: UIColor, backgroundColor: String?) {
|
|
532
|
+
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
|
|
533
|
+
|
|
534
|
+
let fullRange = NSRange(location: 0, length: attributedString.length)
|
|
535
|
+
let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
|
|
536
|
+
|
|
537
|
+
for match in matches.reversed() {
|
|
538
|
+
var contentRange: NSRange?
|
|
539
|
+
for i in 1..<match.numberOfRanges {
|
|
540
|
+
let range = match.range(at: i)
|
|
541
|
+
if range.location != NSNotFound {
|
|
542
|
+
contentRange = range
|
|
543
|
+
break
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
guard let actualContentRange = contentRange else { continue }
|
|
548
|
+
|
|
549
|
+
let content = (attributedString.string as NSString).substring(with: actualContentRange)
|
|
550
|
+
|
|
551
|
+
var newAttributes = attributedString.attributes(at: actualContentRange.location, effectiveRange: nil)
|
|
552
|
+
newAttributes[.font] = font
|
|
553
|
+
newAttributes[.foregroundColor] = color
|
|
554
|
+
|
|
555
|
+
if let bgColorStr = backgroundColor, let bgColor = resolveColor(bgColorStr) {
|
|
556
|
+
newAttributes[.backgroundColor] = bgColor
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let formattedContent = NSAttributedString(string: content, attributes: newAttributes)
|
|
560
|
+
attributedString.replaceCharacters(in: match.range, with: formattedContent)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private func applyStrikethroughPattern(_ pattern: String, to attributedString: NSMutableAttributedString, color: UIColor) {
|
|
565
|
+
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
|
|
566
|
+
|
|
567
|
+
let fullRange = NSRange(location: 0, length: attributedString.length)
|
|
568
|
+
let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
|
|
569
|
+
|
|
570
|
+
for match in matches.reversed() {
|
|
571
|
+
var contentRange: NSRange?
|
|
572
|
+
for i in 1..<match.numberOfRanges {
|
|
573
|
+
let range = match.range(at: i)
|
|
574
|
+
if range.location != NSNotFound {
|
|
575
|
+
contentRange = range
|
|
576
|
+
break
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
guard let actualContentRange = contentRange else { continue }
|
|
581
|
+
|
|
582
|
+
let content = (attributedString.string as NSString).substring(with: actualContentRange)
|
|
583
|
+
|
|
584
|
+
var newAttributes = attributedString.attributes(at: actualContentRange.location, effectiveRange: nil)
|
|
585
|
+
newAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
586
|
+
newAttributes[.foregroundColor] = color
|
|
587
|
+
|
|
588
|
+
let formattedContent = NSAttributedString(string: content, attributes: newAttributes)
|
|
589
|
+
attributedString.replaceCharacters(in: match.range, with: formattedContent)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private func resolveColor(_ hex: String) -> UIColor? {
|
|
594
|
+
guard !hex.isEmpty else { return nil }
|
|
595
|
+
|
|
596
|
+
let start = hex.hasPrefix("#") ? hex.index(hex.startIndex, offsetBy: 1) : hex.startIndex
|
|
597
|
+
let hexColor = String(hex[start...])
|
|
598
|
+
|
|
599
|
+
guard hexColor.count == 6 || hexColor.count == 8 else { return nil }
|
|
600
|
+
|
|
601
|
+
var hexNumber: UInt64 = 0
|
|
602
|
+
guard Scanner(string: hexColor).scanHexInt64(&hexNumber) else { return nil }
|
|
603
|
+
|
|
604
|
+
if hexColor.count == 6 {
|
|
605
|
+
return UIColor(
|
|
606
|
+
red: CGFloat((hexNumber & 0xff0000) >> 16) / 255,
|
|
607
|
+
green: CGFloat((hexNumber & 0x00ff00) >> 8) / 255,
|
|
608
|
+
blue: CGFloat(hexNumber & 0x0000ff) / 255,
|
|
609
|
+
alpha: 1.0
|
|
610
|
+
)
|
|
611
|
+
} else {
|
|
612
|
+
return UIColor(
|
|
613
|
+
red: CGFloat((hexNumber & 0xff000000) >> 24) / 255,
|
|
614
|
+
green: CGFloat((hexNumber & 0x00ff0000) >> 16) / 255,
|
|
615
|
+
blue: CGFloat((hexNumber & 0x0000ff00) >> 8) / 255,
|
|
616
|
+
alpha: CGFloat(hexNumber & 0x000000ff) / 255
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private func resolveFont(_ familyName: String, size: CGFloat) -> UIFont {
|
|
622
|
+
if !familyName.isEmpty, let customFont = UIFont(name: familyName, size: size) {
|
|
623
|
+
return customFont
|
|
624
|
+
}
|
|
625
|
+
return UIFont.systemFont(ofSize: size)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private func updateSelectionColor() {
|
|
629
|
+
if let color = resolveColor(markdownSelectionColor) {
|
|
630
|
+
self.tintColor = color
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private var lastSize: CGSize = .zero
|
|
635
|
+
|
|
636
|
+
override func layoutSubviews() {
|
|
637
|
+
super.layoutSubviews()
|
|
638
|
+
// Force size notification after layout
|
|
639
|
+
DispatchQueue.main.async { [weak self] in
|
|
640
|
+
self?.notifySizeChange()
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
override func didMoveToWindow() {
|
|
645
|
+
super.didMoveToWindow()
|
|
646
|
+
// Trigger size calculation when view is added to window
|
|
647
|
+
if window != nil {
|
|
648
|
+
DispatchQueue.main.async { [weak self] in
|
|
649
|
+
self?.notifySizeChange()
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private func notifySizeChange() {
|
|
655
|
+
var boundingWidth = self.bounds.width
|
|
656
|
+
if boundingWidth <= 0 {
|
|
657
|
+
boundingWidth = self.superview?.bounds.width ?? UIScreen.main.bounds.width
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
let size = self.sizeThatFits(CGSize(width: boundingWidth, height: .greatestFiniteMagnitude))
|
|
661
|
+
|
|
662
|
+
let minHeight = markdownFontSize * 1.5
|
|
663
|
+
let finalHeight = max(size.height, minHeight)
|
|
664
|
+
|
|
665
|
+
if abs(finalHeight - lastSize.height) > 0.5 || abs(size.width - lastSize.width) > 0.5 {
|
|
666
|
+
lastSize = CGSize(width: size.width, height: finalHeight)
|
|
667
|
+
onNativeSizeChange?(["width": size.width, "height": finalHeight])
|
|
668
|
+
self.invalidateIntrinsicContentSize()
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
override var intrinsicContentSize: CGSize {
|
|
673
|
+
var width = self.bounds.width
|
|
674
|
+
if width <= 0 {
|
|
675
|
+
width = self.superview?.bounds.width ?? UIScreen.main.bounds.width
|
|
676
|
+
}
|
|
677
|
+
let size = self.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
|
|
678
|
+
let minHeight = markdownFontSize * 1.5
|
|
679
|
+
return CGSize(width: size.width, height: max(size.height, minHeight))
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// MARK: - Rules Parsing and Application
|
|
683
|
+
|
|
684
|
+
private func parseAndApplyRules() {
|
|
685
|
+
guard !markdownRulesJson.isEmpty else {
|
|
686
|
+
parsedRules = [:]
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
guard let data = markdownRulesJson.data(using: .utf8),
|
|
691
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] else {
|
|
692
|
+
parsedRules = [:]
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
parsedRules = json
|
|
697
|
+
renderContent()
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private func getRuleValue<T>(_ elementType: String, _ key: String, defaultValue: T) -> T {
|
|
701
|
+
guard let rule = parsedRules[elementType],
|
|
702
|
+
let value = rule[key] as? T else {
|
|
703
|
+
return defaultValue
|
|
704
|
+
}
|
|
705
|
+
return value
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private func applyRulesToFont(_ elementType: String, baseSize: CGFloat) -> UIFont {
|
|
709
|
+
let fontSize: CGFloat = getRuleValue(elementType, "fontSize", defaultValue: baseSize)
|
|
710
|
+
let fontFamily: String? = getRuleValue(elementType, "fontFamily", defaultValue: nil as String?)
|
|
711
|
+
let fontWeight: String? = getRuleValue(elementType, "fontWeight", defaultValue: nil as String?)
|
|
712
|
+
let fontStyle: String? = getRuleValue(elementType, "fontStyle", defaultValue: nil as String?)
|
|
713
|
+
|
|
714
|
+
if let family = fontFamily, !family.isEmpty {
|
|
715
|
+
if let customFont = UIFont(name: family, size: fontSize) {
|
|
716
|
+
return applyFontTraits(customFont, weight: fontWeight, style: fontStyle)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Apply weight and style to system font
|
|
721
|
+
var font = UIFont.systemFont(ofSize: fontSize)
|
|
722
|
+
if let weight = fontWeight {
|
|
723
|
+
if weight == "bold" || weight == "700" || weight == "800" || weight == "900" {
|
|
724
|
+
font = UIFont.boldSystemFont(ofSize: fontSize)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if let style = fontStyle, style == "italic" {
|
|
728
|
+
font = UIFont.italicSystemFont(ofSize: fontSize)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return font
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private func applyFontTraits(_ font: UIFont, weight: String?, style: String?) -> UIFont {
|
|
735
|
+
var traits: UIFontDescriptor.SymbolicTraits = []
|
|
736
|
+
|
|
737
|
+
if let w = weight, (w == "bold" || w == "700" || w == "800" || w == "900") {
|
|
738
|
+
traits.insert(.traitBold)
|
|
739
|
+
}
|
|
740
|
+
if let s = style, s == "italic" {
|
|
741
|
+
traits.insert(.traitItalic)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if let descriptor = font.fontDescriptor.withSymbolicTraits(traits) {
|
|
745
|
+
return UIFont(descriptor: descriptor, size: font.pointSize)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return font
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private func applyRulesToParagraphStyle(_ elementType: String, base: NSMutableParagraphStyle) -> NSMutableParagraphStyle {
|
|
752
|
+
let style = NSMutableParagraphStyle()
|
|
753
|
+
style.lineSpacing = base.lineSpacing
|
|
754
|
+
style.paragraphSpacing = getRuleValue(elementType, "marginBottom", defaultValue: base.paragraphSpacing)
|
|
755
|
+
style.paragraphSpacingBefore = getRuleValue(elementType, "marginTop", defaultValue: base.paragraphSpacingBefore)
|
|
756
|
+
style.firstLineHeadIndent = base.firstLineHeadIndent
|
|
757
|
+
style.headIndent = base.headIndent
|
|
758
|
+
|
|
759
|
+
return style
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private func applyRulesToColor(_ elementType: String, defaultColor: UIColor) -> UIColor {
|
|
763
|
+
let colorStr: String? = getRuleValue(elementType, "color", defaultValue: nil)
|
|
764
|
+
if let str = colorStr, let color = resolveColor(str) {
|
|
765
|
+
return color
|
|
766
|
+
}
|
|
767
|
+
return defaultColor
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
|