@chaitrabhairappa/react-native-rich-text-editor 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/README.md +220 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/richtext/editor/FloatingToolbar.kt +350 -0
- package/android/src/main/java/com/richtext/editor/RichTextEditorPackage.kt +16 -0
- package/android/src/main/java/com/richtext/editor/RichTextEditorView.kt +1292 -0
- package/android/src/main/java/com/richtext/editor/RichTextEditorViewManager.kt +236 -0
- package/ios/RichTextEditorView.swift +1574 -0
- package/ios/RichTextEditorViewManager.m +45 -0
- package/ios/RichTextEditorViewManager.swift +235 -0
- package/lib/commonjs/index.js +156 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/types.js +8 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/index.js +143 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/src/index.d.ts +7 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +76 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +78 -0
- package/react-native-richtext-editor.podspec +21 -0
- package/src/index.tsx +199 -0
- package/src/types.ts +125 -0
|
@@ -0,0 +1,1574 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import React
|
|
3
|
+
|
|
4
|
+
class FloatingToolbar: UIView, UIScrollViewDelegate {
|
|
5
|
+
weak var editorView: RichTextEditorView?
|
|
6
|
+
|
|
7
|
+
private var buttons: [UIButton] = []
|
|
8
|
+
private var scrollView: UIScrollView!
|
|
9
|
+
private var stackView: UIStackView!
|
|
10
|
+
private var leftArrow: UILabel!
|
|
11
|
+
private var rightArrow: UILabel!
|
|
12
|
+
private var enabledOptions: [String] = [
|
|
13
|
+
"bold", "italic", "underline", "strikethrough", "code", "highlight",
|
|
14
|
+
"heading", "bullet", "numbered", "quote", "checklist",
|
|
15
|
+
"link", "undo", "redo", "clearFormatting",
|
|
16
|
+
"indent", "outdent",
|
|
17
|
+
"alignLeft", "alignCenter", "alignRight"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
private let optionToIndex: [String: Int] = [
|
|
21
|
+
"bold": 0, "italic": 1, "strikethrough": 2, "underline": 3, "code": 4, "highlight": 5,
|
|
22
|
+
"heading": 6, "bullet": 7, "numbered": 8, "quote": 9, "checklist": 10,
|
|
23
|
+
"link": 11, "undo": 12, "redo": 13, "clearFormatting": 14,
|
|
24
|
+
"indent": 15, "outdent": 16,
|
|
25
|
+
"alignLeft": 17, "alignCenter": 18, "alignRight": 19
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
private let toolbarBackgroundColor = UIColor(red: 45/255, green: 45/255, blue: 45/255, alpha: 1.0)
|
|
29
|
+
private let activeColor = UIColor(red: 80/255, green: 130/255, blue: 200/255, alpha: 1.0)
|
|
30
|
+
private let inactiveColor = UIColor.white
|
|
31
|
+
private let arrowColor = UIColor(white: 1.0, alpha: 0.7)
|
|
32
|
+
|
|
33
|
+
override init(frame: CGRect) {
|
|
34
|
+
super.init(frame: frame)
|
|
35
|
+
setupToolbar()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
required init?(coder: NSCoder) {
|
|
39
|
+
super.init(coder: coder)
|
|
40
|
+
setupToolbar()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func setToolbarOptions(_ options: [String]?) {
|
|
44
|
+
if let options = options, !options.isEmpty {
|
|
45
|
+
enabledOptions = options
|
|
46
|
+
} else {
|
|
47
|
+
enabledOptions = [
|
|
48
|
+
"bold", "italic", "underline", "strikethrough", "code", "highlight",
|
|
49
|
+
"heading", "bullet", "numbered", "quote", "checklist",
|
|
50
|
+
"link", "undo", "redo", "clearFormatting",
|
|
51
|
+
"indent", "outdent",
|
|
52
|
+
"alignLeft", "alignCenter", "alignRight"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
rebuildButtons()
|
|
56
|
+
updateScrollIndicators()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private func setupToolbar() {
|
|
60
|
+
backgroundColor = toolbarBackgroundColor
|
|
61
|
+
layer.cornerRadius = 10
|
|
62
|
+
layer.shadowColor = UIColor.black.cgColor
|
|
63
|
+
layer.shadowOffset = CGSize(width: 0, height: 2)
|
|
64
|
+
layer.shadowOpacity = 0.3
|
|
65
|
+
layer.shadowRadius = 6
|
|
66
|
+
|
|
67
|
+
leftArrow = UILabel()
|
|
68
|
+
leftArrow.text = "‹"
|
|
69
|
+
leftArrow.font = UIFont.systemFont(ofSize: 20, weight: .bold)
|
|
70
|
+
leftArrow.textColor = arrowColor
|
|
71
|
+
leftArrow.textAlignment = .center
|
|
72
|
+
leftArrow.translatesAutoresizingMaskIntoConstraints = false
|
|
73
|
+
leftArrow.isHidden = true
|
|
74
|
+
leftArrow.isUserInteractionEnabled = true
|
|
75
|
+
let leftTap = UITapGestureRecognizer(target: self, action: #selector(leftArrowTapped))
|
|
76
|
+
leftArrow.addGestureRecognizer(leftTap)
|
|
77
|
+
addSubview(leftArrow)
|
|
78
|
+
|
|
79
|
+
rightArrow = UILabel()
|
|
80
|
+
rightArrow.text = "›"
|
|
81
|
+
rightArrow.font = UIFont.systemFont(ofSize: 20, weight: .bold)
|
|
82
|
+
rightArrow.textColor = arrowColor
|
|
83
|
+
rightArrow.textAlignment = .center
|
|
84
|
+
rightArrow.translatesAutoresizingMaskIntoConstraints = false
|
|
85
|
+
rightArrow.isHidden = false
|
|
86
|
+
rightArrow.isUserInteractionEnabled = true
|
|
87
|
+
let rightTap = UITapGestureRecognizer(target: self, action: #selector(rightArrowTapped))
|
|
88
|
+
rightArrow.addGestureRecognizer(rightTap)
|
|
89
|
+
addSubview(rightArrow)
|
|
90
|
+
|
|
91
|
+
scrollView = UIScrollView()
|
|
92
|
+
scrollView.showsHorizontalScrollIndicator = false
|
|
93
|
+
scrollView.showsVerticalScrollIndicator = false
|
|
94
|
+
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
95
|
+
scrollView.delegate = self
|
|
96
|
+
addSubview(scrollView)
|
|
97
|
+
|
|
98
|
+
stackView = UIStackView()
|
|
99
|
+
stackView.axis = .horizontal
|
|
100
|
+
stackView.spacing = 8
|
|
101
|
+
stackView.distribution = .fill
|
|
102
|
+
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
103
|
+
scrollView.addSubview(stackView)
|
|
104
|
+
|
|
105
|
+
NSLayoutConstraint.activate([
|
|
106
|
+
leftArrow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
|
|
107
|
+
leftArrow.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
108
|
+
leftArrow.widthAnchor.constraint(equalToConstant: 16),
|
|
109
|
+
|
|
110
|
+
rightArrow.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
|
|
111
|
+
rightArrow.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
112
|
+
rightArrow.widthAnchor.constraint(equalToConstant: 16),
|
|
113
|
+
|
|
114
|
+
scrollView.leadingAnchor.constraint(equalTo: leftArrow.trailingAnchor, constant: 2),
|
|
115
|
+
scrollView.trailingAnchor.constraint(equalTo: rightArrow.leadingAnchor, constant: -2),
|
|
116
|
+
scrollView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
|
117
|
+
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
|
118
|
+
|
|
119
|
+
stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
|
|
120
|
+
stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
|
|
121
|
+
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor),
|
|
122
|
+
stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
|
|
123
|
+
stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor)
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
rebuildButtons()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
130
|
+
updateScrollIndicators()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func updateScrollIndicators() {
|
|
134
|
+
guard let scrollView = scrollView else { return }
|
|
135
|
+
|
|
136
|
+
let contentWidth = scrollView.contentSize.width
|
|
137
|
+
let scrollViewWidth = scrollView.bounds.width
|
|
138
|
+
let offsetX = scrollView.contentOffset.x
|
|
139
|
+
|
|
140
|
+
leftArrow.isHidden = offsetX <= 5
|
|
141
|
+
rightArrow.isHidden = offsetX >= (contentWidth - scrollViewWidth - 5)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@objc private func leftArrowTapped() {
|
|
145
|
+
guard let scrollView = scrollView else { return }
|
|
146
|
+
let scrollAmount: CGFloat = 120
|
|
147
|
+
let newOffsetX = max(0, scrollView.contentOffset.x - scrollAmount)
|
|
148
|
+
scrollView.setContentOffset(CGPoint(x: newOffsetX, y: 0), animated: true)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@objc private func rightArrowTapped() {
|
|
152
|
+
guard let scrollView = scrollView else { return }
|
|
153
|
+
let scrollAmount: CGFloat = 120
|
|
154
|
+
let maxOffsetX = scrollView.contentSize.width - scrollView.bounds.width
|
|
155
|
+
let newOffsetX = min(maxOffsetX, scrollView.contentOffset.x + scrollAmount)
|
|
156
|
+
scrollView.setContentOffset(CGPoint(x: newOffsetX, y: 0), animated: true)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
override func layoutSubviews() {
|
|
160
|
+
super.layoutSubviews()
|
|
161
|
+
DispatchQueue.main.async {
|
|
162
|
+
self.updateScrollIndicators()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private func rebuildButtons() {
|
|
167
|
+
buttons.forEach { $0.removeFromSuperview() }
|
|
168
|
+
buttons.removeAll()
|
|
169
|
+
|
|
170
|
+
for option in enabledOptions {
|
|
171
|
+
guard let index = optionToIndex[option] else { continue }
|
|
172
|
+
|
|
173
|
+
let button = UIButton(type: .system)
|
|
174
|
+
button.tag = index
|
|
175
|
+
button.layer.cornerRadius = 6
|
|
176
|
+
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
|
|
177
|
+
button.widthAnchor.constraint(equalToConstant: 36).isActive = true
|
|
178
|
+
button.heightAnchor.constraint(equalToConstant: 36).isActive = true
|
|
179
|
+
|
|
180
|
+
button.titleLabel?.numberOfLines = 0
|
|
181
|
+
button.titleLabel?.lineBreakMode = .byWordWrapping
|
|
182
|
+
button.titleLabel?.textAlignment = .center
|
|
183
|
+
|
|
184
|
+
let attrString = createButtonAttributedString(for: index, active: false)
|
|
185
|
+
button.setAttributedTitle(attrString, for: .normal)
|
|
186
|
+
|
|
187
|
+
buttons.append(button)
|
|
188
|
+
stackView.addArrangedSubview(button)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func getToolbarWidth() -> CGFloat {
|
|
193
|
+
let screenWidth = UIScreen.main.bounds.width
|
|
194
|
+
let maxWidth = screenWidth * 0.9
|
|
195
|
+
let buttonCount = CGFloat(enabledOptions.count)
|
|
196
|
+
let calculatedWidth = (buttonCount * 36) + ((buttonCount - 1) * 8) + 48
|
|
197
|
+
return min(calculatedWidth, maxWidth)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private func createButtonAttributedString(for index: Int, active: Bool) -> NSAttributedString {
|
|
201
|
+
let color = active ? activeColor : inactiveColor
|
|
202
|
+
let fontSize: CGFloat = 18
|
|
203
|
+
|
|
204
|
+
switch index {
|
|
205
|
+
case 0:
|
|
206
|
+
return NSAttributedString(string: "B", attributes: [
|
|
207
|
+
.font: UIFont.boldSystemFont(ofSize: fontSize),
|
|
208
|
+
.foregroundColor: color
|
|
209
|
+
])
|
|
210
|
+
case 1:
|
|
211
|
+
return NSAttributedString(string: "I", attributes: [
|
|
212
|
+
.font: UIFont.italicSystemFont(ofSize: fontSize),
|
|
213
|
+
.foregroundColor: color
|
|
214
|
+
])
|
|
215
|
+
case 2:
|
|
216
|
+
return NSAttributedString(string: "S", attributes: [
|
|
217
|
+
.font: UIFont.systemFont(ofSize: fontSize, weight: .medium),
|
|
218
|
+
.foregroundColor: color,
|
|
219
|
+
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
|
|
220
|
+
.strikethroughColor: color
|
|
221
|
+
])
|
|
222
|
+
case 3:
|
|
223
|
+
return NSAttributedString(string: "U", attributes: [
|
|
224
|
+
.font: UIFont.systemFont(ofSize: fontSize, weight: .medium),
|
|
225
|
+
.foregroundColor: color,
|
|
226
|
+
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
|
227
|
+
.underlineColor: color
|
|
228
|
+
])
|
|
229
|
+
case 4:
|
|
230
|
+
return NSAttributedString(string: "</>", attributes: [
|
|
231
|
+
.font: UIFont(name: "Menlo", size: fontSize - 4) ?? UIFont.monospacedSystemFont(ofSize: fontSize - 4, weight: .medium),
|
|
232
|
+
.foregroundColor: color
|
|
233
|
+
])
|
|
234
|
+
case 5:
|
|
235
|
+
return NSAttributedString(string: "H", attributes: [
|
|
236
|
+
.font: UIFont.systemFont(ofSize: fontSize, weight: .medium),
|
|
237
|
+
.foregroundColor: color,
|
|
238
|
+
.backgroundColor: active ? UIColor.yellow.withAlphaComponent(0.5) : UIColor.yellow.withAlphaComponent(0.3)
|
|
239
|
+
])
|
|
240
|
+
case 6:
|
|
241
|
+
return NSAttributedString(string: "H1", attributes: [
|
|
242
|
+
.font: UIFont.boldSystemFont(ofSize: fontSize - 2),
|
|
243
|
+
.foregroundColor: color
|
|
244
|
+
])
|
|
245
|
+
case 7:
|
|
246
|
+
return createListIcon(type: .bullet, color: color)
|
|
247
|
+
case 8:
|
|
248
|
+
return createListIcon(type: .numbered, color: color)
|
|
249
|
+
case 9:
|
|
250
|
+
return NSAttributedString(string: "❞", attributes: [
|
|
251
|
+
.font: UIFont.systemFont(ofSize: fontSize + 2),
|
|
252
|
+
.foregroundColor: color
|
|
253
|
+
])
|
|
254
|
+
case 10:
|
|
255
|
+
return NSAttributedString(string: "☑", attributes: [
|
|
256
|
+
.font: UIFont.systemFont(ofSize: fontSize),
|
|
257
|
+
.foregroundColor: color
|
|
258
|
+
])
|
|
259
|
+
case 11:
|
|
260
|
+
return NSAttributedString(string: "🔗", attributes: [
|
|
261
|
+
.font: UIFont.systemFont(ofSize: fontSize - 2),
|
|
262
|
+
.foregroundColor: color
|
|
263
|
+
])
|
|
264
|
+
case 12:
|
|
265
|
+
return NSAttributedString(string: "↩", attributes: [
|
|
266
|
+
.font: UIFont.systemFont(ofSize: fontSize),
|
|
267
|
+
.foregroundColor: color
|
|
268
|
+
])
|
|
269
|
+
case 13:
|
|
270
|
+
return NSAttributedString(string: "↪", attributes: [
|
|
271
|
+
.font: UIFont.systemFont(ofSize: fontSize),
|
|
272
|
+
.foregroundColor: color
|
|
273
|
+
])
|
|
274
|
+
case 14:
|
|
275
|
+
return NSAttributedString(string: "Tx", attributes: [
|
|
276
|
+
.font: UIFont.systemFont(ofSize: fontSize - 2, weight: .medium),
|
|
277
|
+
.foregroundColor: color,
|
|
278
|
+
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
|
|
279
|
+
.strikethroughColor: color
|
|
280
|
+
])
|
|
281
|
+
case 15:
|
|
282
|
+
return NSAttributedString(string: "→⊢", attributes: [
|
|
283
|
+
.font: UIFont.systemFont(ofSize: fontSize - 4),
|
|
284
|
+
.foregroundColor: color
|
|
285
|
+
])
|
|
286
|
+
case 16:
|
|
287
|
+
return NSAttributedString(string: "⊣←", attributes: [
|
|
288
|
+
.font: UIFont.systemFont(ofSize: fontSize - 4),
|
|
289
|
+
.foregroundColor: color
|
|
290
|
+
])
|
|
291
|
+
case 17:
|
|
292
|
+
return createAlignmentIcon(alignment: .left, color: color)
|
|
293
|
+
case 18:
|
|
294
|
+
return createAlignmentIcon(alignment: .center, color: color)
|
|
295
|
+
case 19:
|
|
296
|
+
return createAlignmentIcon(alignment: .right, color: color)
|
|
297
|
+
default:
|
|
298
|
+
return NSAttributedString(string: "")
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private func createAlignmentIcon(alignment: NSTextAlignment, color: UIColor) -> NSAttributedString {
|
|
303
|
+
let result = NSMutableAttributedString()
|
|
304
|
+
let paragraphStyle = NSMutableParagraphStyle()
|
|
305
|
+
paragraphStyle.lineSpacing = 1
|
|
306
|
+
paragraphStyle.alignment = alignment
|
|
307
|
+
paragraphStyle.lineHeightMultiple = 0.9
|
|
308
|
+
|
|
309
|
+
let attrs: [NSAttributedString.Key: Any] = [
|
|
310
|
+
.font: UIFont.systemFont(ofSize: 8, weight: .medium),
|
|
311
|
+
.foregroundColor: color,
|
|
312
|
+
.paragraphStyle: paragraphStyle
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
let lines = ["────", "──────", "────"]
|
|
316
|
+
for (i, line) in lines.enumerated() {
|
|
317
|
+
result.append(NSAttributedString(string: line, attributes: attrs))
|
|
318
|
+
if i < lines.count - 1 {
|
|
319
|
+
result.append(NSAttributedString(string: "\n", attributes: attrs))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return result
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private enum ListIconType {
|
|
327
|
+
case bullet
|
|
328
|
+
case numbered
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private func createListIcon(type: ListIconType, color: UIColor) -> NSAttributedString {
|
|
332
|
+
let result = NSMutableAttributedString()
|
|
333
|
+
|
|
334
|
+
let paragraphStyle = NSMutableParagraphStyle()
|
|
335
|
+
paragraphStyle.lineSpacing = 1
|
|
336
|
+
paragraphStyle.alignment = .left
|
|
337
|
+
paragraphStyle.lineHeightMultiple = 0.9
|
|
338
|
+
|
|
339
|
+
let attrs: [NSAttributedString.Key: Any] = [
|
|
340
|
+
.font: UIFont.systemFont(ofSize: 9, weight: .medium),
|
|
341
|
+
.foregroundColor: color,
|
|
342
|
+
.paragraphStyle: paragraphStyle
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
for i in 0..<3 {
|
|
346
|
+
let marker = type == .bullet ? "•" : "\(i + 1)"
|
|
347
|
+
let line = "\(marker) ──"
|
|
348
|
+
result.append(NSAttributedString(string: line, attributes: attrs))
|
|
349
|
+
if i < 2 {
|
|
350
|
+
result.append(NSAttributedString(string: "\n", attributes: attrs))
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return result
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
@objc private func buttonTapped(_ sender: UIButton) {
|
|
358
|
+
switch sender.tag {
|
|
359
|
+
case 0: editorView?.toggleBold()
|
|
360
|
+
case 1: editorView?.toggleItalic()
|
|
361
|
+
case 2: editorView?.toggleStrikethrough()
|
|
362
|
+
case 3: editorView?.toggleUnderline()
|
|
363
|
+
case 4: editorView?.toggleCode()
|
|
364
|
+
case 5: editorView?.toggleHighlight(color: nil)
|
|
365
|
+
case 6: editorView?.setHeading()
|
|
366
|
+
case 7: editorView?.toggleBulletList()
|
|
367
|
+
case 8: editorView?.toggleNumberedList()
|
|
368
|
+
case 9: editorView?.setQuote()
|
|
369
|
+
case 10: editorView?.setChecklist()
|
|
370
|
+
case 11: editorView?.promptInsertLink()
|
|
371
|
+
case 12: editorView?.undo()
|
|
372
|
+
case 13: editorView?.redo()
|
|
373
|
+
case 14: editorView?.clearFormatting()
|
|
374
|
+
case 15: editorView?.indent()
|
|
375
|
+
case 16: editorView?.outdent()
|
|
376
|
+
case 17: editorView?.setAlignment(.left)
|
|
377
|
+
case 18: editorView?.setAlignment(.center)
|
|
378
|
+
case 19: editorView?.setAlignment(.right)
|
|
379
|
+
default: break
|
|
380
|
+
}
|
|
381
|
+
editorView?.updateToolbarButtonStates()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
func updateButtonStates(bold: Bool, italic: Bool, underline: Bool, strikethrough: Bool, code: Bool = false, highlight: Bool = false, heading: Bool = false, bullet: Bool, numbered: Bool, quote: Bool = false, checklist: Bool = false, alignLeft: Bool = true, alignCenter: Bool = false, alignRight: Bool = false) {
|
|
385
|
+
let styleStates: [Int: Bool] = [
|
|
386
|
+
0: bold, 1: italic, 2: strikethrough, 3: underline, 4: code, 5: highlight,
|
|
387
|
+
6: heading, 7: bullet, 8: numbered, 9: quote, 10: checklist,
|
|
388
|
+
11: false, 12: false, 13: false, 14: false,
|
|
389
|
+
15: false, 16: false,
|
|
390
|
+
17: alignLeft, 18: alignCenter, 19: alignRight
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
for button in buttons {
|
|
394
|
+
let tag = button.tag
|
|
395
|
+
let isActive = styleStates[tag] ?? false
|
|
396
|
+
let attrString = createButtonAttributedString(for: tag, active: isActive)
|
|
397
|
+
button.setAttributedTitle(attrString, for: .normal)
|
|
398
|
+
button.backgroundColor = isActive ? toolbarBackgroundColor.withAlphaComponent(0.5) : .clear
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
class RichTextView: UITextView {
|
|
404
|
+
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
405
|
+
super.init(frame: frame, textContainer: textContainer)
|
|
406
|
+
disableAutofill()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
required init?(coder: NSCoder) {
|
|
410
|
+
super.init(coder: coder)
|
|
411
|
+
disableAutofill()
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private func disableAutofill() {
|
|
415
|
+
autocorrectionType = .no
|
|
416
|
+
autocapitalizationType = .none
|
|
417
|
+
spellCheckingType = .no
|
|
418
|
+
smartQuotesType = .no
|
|
419
|
+
smartDashesType = .no
|
|
420
|
+
smartInsertDeleteType = .no
|
|
421
|
+
|
|
422
|
+
if #available(iOS 10.0, *) {
|
|
423
|
+
textContentType = UITextContentType(rawValue: "")
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if #available(iOS 9.0, *) {
|
|
427
|
+
inputAssistantItem.leadingBarButtonGroups = []
|
|
428
|
+
inputAssistantItem.trailingBarButtonGroups = []
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
inputAccessoryView = UIView(frame: .zero)
|
|
432
|
+
|
|
433
|
+
if #available(iOS 16.0, *) {
|
|
434
|
+
isFindInteractionEnabled = false
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
isSecureTextEntry = true
|
|
438
|
+
isSecureTextEntry = false
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
|
442
|
+
return false
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
class RichTextEditorView: UIView, UITextViewDelegate {
|
|
447
|
+
private let textView: RichTextView = {
|
|
448
|
+
let tv = RichTextView()
|
|
449
|
+
tv.font = UIFont.systemFont(ofSize: 16)
|
|
450
|
+
tv.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
|
|
451
|
+
tv.translatesAutoresizingMaskIntoConstraints = false
|
|
452
|
+
tv.backgroundColor = .clear
|
|
453
|
+
return tv
|
|
454
|
+
}()
|
|
455
|
+
|
|
456
|
+
private let placeholderLabel: UILabel = {
|
|
457
|
+
let label = UILabel()
|
|
458
|
+
label.textColor = UIColor.placeholderText
|
|
459
|
+
label.font = UIFont.systemFont(ofSize: 16)
|
|
460
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
461
|
+
return label
|
|
462
|
+
}()
|
|
463
|
+
|
|
464
|
+
private let floatingToolbar: FloatingToolbar = {
|
|
465
|
+
let toolbar = FloatingToolbar()
|
|
466
|
+
toolbar.translatesAutoresizingMaskIntoConstraints = true
|
|
467
|
+
toolbar.isHidden = true
|
|
468
|
+
return toolbar
|
|
469
|
+
}()
|
|
470
|
+
|
|
471
|
+
private lazy var toolbarBackdrop: UIView = {
|
|
472
|
+
let view = UIView()
|
|
473
|
+
view.backgroundColor = .clear
|
|
474
|
+
view.isUserInteractionEnabled = false
|
|
475
|
+
view.isHidden = true
|
|
476
|
+
return view
|
|
477
|
+
}()
|
|
478
|
+
|
|
479
|
+
private var maxHeightConstraint: NSLayoutConstraint?
|
|
480
|
+
private var undoStack: [NSAttributedString] = []
|
|
481
|
+
private var redoStack: [NSAttributedString] = []
|
|
482
|
+
private var isInternalChange = false
|
|
483
|
+
private var currentKeyboardHeight: CGFloat = 0
|
|
484
|
+
|
|
485
|
+
@objc var placeholder: String = "" {
|
|
486
|
+
didSet { placeholderLabel.text = placeholder }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
@objc var editable: Bool = true {
|
|
490
|
+
didSet { textView.isEditable = editable }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
@objc var maxHeight: CGFloat = 0 {
|
|
494
|
+
didSet {
|
|
495
|
+
if maxHeight > 0 {
|
|
496
|
+
maxHeightConstraint?.isActive = false
|
|
497
|
+
maxHeightConstraint = textView.heightAnchor.constraint(lessThanOrEqualToConstant: maxHeight)
|
|
498
|
+
maxHeightConstraint?.isActive = true
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
@objc var showToolbar: Bool = true
|
|
504
|
+
|
|
505
|
+
@objc var toolbarOptions: [String]? {
|
|
506
|
+
didSet {
|
|
507
|
+
floatingToolbar.setToolbarOptions(toolbarOptions)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@objc var initialContent: [[String: Any]]? {
|
|
512
|
+
didSet {
|
|
513
|
+
if let blocks = initialContent {
|
|
514
|
+
setContent(blocks: blocks)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
@objc var variant: String = "outlined" {
|
|
520
|
+
didSet {
|
|
521
|
+
applyVariantStyle()
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
@objc var onContentChange: RCTDirectEventBlock?
|
|
526
|
+
@objc var onSelectionChange: RCTDirectEventBlock?
|
|
527
|
+
@objc var onEditorFocus: RCTDirectEventBlock?
|
|
528
|
+
@objc var onEditorBlur: RCTDirectEventBlock?
|
|
529
|
+
@objc var onSizeChange: RCTDirectEventBlock?
|
|
530
|
+
|
|
531
|
+
private var lastReportedHeight: CGFloat = 0
|
|
532
|
+
private var calculatedHeight: CGFloat = 44
|
|
533
|
+
|
|
534
|
+
override init(frame: CGRect) {
|
|
535
|
+
super.init(frame: frame)
|
|
536
|
+
setupView()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
required init?(coder: NSCoder) {
|
|
540
|
+
super.init(coder: coder)
|
|
541
|
+
setupView()
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private lazy var bottomBorder: UIView = {
|
|
545
|
+
let view = UIView()
|
|
546
|
+
view.backgroundColor = UIColor.separator
|
|
547
|
+
view.translatesAutoresizingMaskIntoConstraints = false
|
|
548
|
+
view.isHidden = true
|
|
549
|
+
return view
|
|
550
|
+
}()
|
|
551
|
+
|
|
552
|
+
private func setupView() {
|
|
553
|
+
backgroundColor = .systemBackground
|
|
554
|
+
|
|
555
|
+
addSubview(textView)
|
|
556
|
+
addSubview(placeholderLabel)
|
|
557
|
+
addSubview(bottomBorder)
|
|
558
|
+
|
|
559
|
+
NSLayoutConstraint.activate([
|
|
560
|
+
bottomBorder.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
561
|
+
bottomBorder.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
562
|
+
bottomBorder.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
563
|
+
bottomBorder.heightAnchor.constraint(equalToConstant: 1)
|
|
564
|
+
])
|
|
565
|
+
|
|
566
|
+
applyVariantStyle()
|
|
567
|
+
|
|
568
|
+
floatingToolbar.editorView = self
|
|
569
|
+
|
|
570
|
+
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backdropTapped))
|
|
571
|
+
toolbarBackdrop.addGestureRecognizer(tapGesture)
|
|
572
|
+
|
|
573
|
+
DispatchQueue.main.async { [weak self] in
|
|
574
|
+
if let window = self?.window {
|
|
575
|
+
window.addSubview(self!.toolbarBackdrop)
|
|
576
|
+
window.addSubview(self!.floatingToolbar)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
NSLayoutConstraint.activate([
|
|
581
|
+
textView.topAnchor.constraint(equalTo: topAnchor),
|
|
582
|
+
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
583
|
+
textView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
584
|
+
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
585
|
+
|
|
586
|
+
placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor, constant: 12),
|
|
587
|
+
placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 13)
|
|
588
|
+
])
|
|
589
|
+
|
|
590
|
+
textView.delegate = self
|
|
591
|
+
textView.isScrollEnabled = false
|
|
592
|
+
|
|
593
|
+
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange), name: UITextView.textDidChangeNotification, object: textView)
|
|
594
|
+
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
|
595
|
+
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
@objc private func keyboardWillShow(_ notification: Notification) {
|
|
599
|
+
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
|
600
|
+
currentKeyboardHeight = keyboardFrame.height
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
@objc private func keyboardWillHide(_ notification: Notification) {
|
|
605
|
+
currentKeyboardHeight = 0
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
@objc private func backdropTapped() {
|
|
609
|
+
hideToolbar()
|
|
610
|
+
textView.selectedRange = NSRange(location: textView.selectedRange.location, length: 0)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private func hideToolbar() {
|
|
614
|
+
floatingToolbar.isHidden = true
|
|
615
|
+
toolbarBackdrop.isHidden = true
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
override func didMoveToWindow() {
|
|
619
|
+
super.didMoveToWindow()
|
|
620
|
+
if let window = window {
|
|
621
|
+
toolbarBackdrop.removeFromSuperview()
|
|
622
|
+
floatingToolbar.removeFromSuperview()
|
|
623
|
+
window.addSubview(toolbarBackdrop)
|
|
624
|
+
window.addSubview(floatingToolbar)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
override func removeFromSuperview() {
|
|
629
|
+
toolbarBackdrop.removeFromSuperview()
|
|
630
|
+
floatingToolbar.removeFromSuperview()
|
|
631
|
+
super.removeFromSuperview()
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
deinit {
|
|
635
|
+
toolbarBackdrop.removeFromSuperview()
|
|
636
|
+
floatingToolbar.removeFromSuperview()
|
|
637
|
+
NotificationCenter.default.removeObserver(self)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private func updateToolbarPosition() {
|
|
641
|
+
guard let selectedRange = textView.selectedTextRange,
|
|
642
|
+
!selectedRange.isEmpty,
|
|
643
|
+
showToolbar,
|
|
644
|
+
let window = window else {
|
|
645
|
+
hideToolbar()
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
let selectionRect = textView.firstRect(for: selectedRange)
|
|
650
|
+
|
|
651
|
+
guard !selectionRect.isNull && !selectionRect.isInfinite && selectionRect.width > 0 else {
|
|
652
|
+
hideToolbar()
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
let convertedRect = textView.convert(selectionRect, to: window)
|
|
657
|
+
|
|
658
|
+
guard convertedRect.minY > 0 && convertedRect.maxY < window.bounds.height &&
|
|
659
|
+
convertedRect.minX >= 0 && convertedRect.maxX <= window.bounds.width else {
|
|
660
|
+
hideToolbar()
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
let toolbarWidth: CGFloat = floatingToolbar.getToolbarWidth()
|
|
665
|
+
let toolbarHeight: CGFloat = 52
|
|
666
|
+
|
|
667
|
+
let safeAreaTop = window.safeAreaInsets.top
|
|
668
|
+
let safeAreaBottom = window.safeAreaInsets.bottom
|
|
669
|
+
|
|
670
|
+
var toolbarX = (window.bounds.width - toolbarWidth) / 2
|
|
671
|
+
var toolbarY = convertedRect.maxY + 8
|
|
672
|
+
|
|
673
|
+
toolbarX = max(8, min(toolbarX, window.bounds.width - toolbarWidth - 8))
|
|
674
|
+
|
|
675
|
+
let maxY = window.bounds.height - safeAreaBottom - currentKeyboardHeight - toolbarHeight - 8
|
|
676
|
+
if toolbarY > maxY {
|
|
677
|
+
toolbarY = convertedRect.minY - toolbarHeight - 8
|
|
678
|
+
if toolbarY < safeAreaTop + 8 {
|
|
679
|
+
toolbarY = safeAreaTop + 8
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
toolbarBackdrop.frame = window.bounds
|
|
684
|
+
toolbarBackdrop.isHidden = false
|
|
685
|
+
window.bringSubviewToFront(toolbarBackdrop)
|
|
686
|
+
|
|
687
|
+
floatingToolbar.frame = CGRect(x: toolbarX, y: toolbarY, width: toolbarWidth, height: toolbarHeight)
|
|
688
|
+
floatingToolbar.isHidden = false
|
|
689
|
+
window.bringSubviewToFront(floatingToolbar)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
693
|
+
onEditorFocus?([:])
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
func textViewDidEndEditing(_ textView: UITextView) {
|
|
697
|
+
hideToolbar()
|
|
698
|
+
onEditorBlur?([:])
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
702
|
+
updateToolbarPosition()
|
|
703
|
+
updateToolbarButtonStates()
|
|
704
|
+
|
|
705
|
+
let range = textView.selectedRange
|
|
706
|
+
onSelectionChange?([
|
|
707
|
+
"start": range.location,
|
|
708
|
+
"end": range.location + range.length
|
|
709
|
+
])
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
|
713
|
+
guard text == "\n" else { return true }
|
|
714
|
+
|
|
715
|
+
let currentText = textView.text ?? ""
|
|
716
|
+
let nsText = currentText as NSString
|
|
717
|
+
|
|
718
|
+
var lineStart = range.location
|
|
719
|
+
while lineStart > 0 && nsText.character(at: lineStart - 1) != 10 {
|
|
720
|
+
lineStart -= 1
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
let lineLength = range.location - lineStart
|
|
724
|
+
let currentLine = nsText.substring(with: NSRange(location: lineStart, length: lineLength))
|
|
725
|
+
|
|
726
|
+
if currentLine.hasPrefix("• ") {
|
|
727
|
+
let lineContent = String(currentLine.dropFirst(2))
|
|
728
|
+
if lineContent.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
729
|
+
let deleteRange = NSRange(location: lineStart, length: lineLength)
|
|
730
|
+
textView.selectedRange = deleteRange
|
|
731
|
+
textView.insertText("")
|
|
732
|
+
return false
|
|
733
|
+
}
|
|
734
|
+
let plainAttributes: [NSAttributedString.Key: Any] = [
|
|
735
|
+
.font: UIFont.systemFont(ofSize: 16),
|
|
736
|
+
.foregroundColor: UIColor.label
|
|
737
|
+
]
|
|
738
|
+
textView.typingAttributes = plainAttributes
|
|
739
|
+
textView.insertText("\n• ")
|
|
740
|
+
return false
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
let numberedPattern = "^(\\d+)\\.\\s"
|
|
744
|
+
if let regex = try? NSRegularExpression(pattern: numberedPattern),
|
|
745
|
+
let match = regex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.count)),
|
|
746
|
+
let numberRange = Range(match.range(at: 1), in: currentLine) {
|
|
747
|
+
|
|
748
|
+
let currentNumber = Int(currentLine[numberRange]) ?? 1
|
|
749
|
+
let lineContent = String(currentLine.dropFirst(match.range.length))
|
|
750
|
+
|
|
751
|
+
if lineContent.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
752
|
+
let deleteRange = NSRange(location: lineStart, length: lineLength)
|
|
753
|
+
textView.selectedRange = deleteRange
|
|
754
|
+
textView.insertText("")
|
|
755
|
+
return false
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
let nextNumber = currentNumber + 1
|
|
759
|
+
let plainAttributes: [NSAttributedString.Key: Any] = [
|
|
760
|
+
.font: UIFont.systemFont(ofSize: 16),
|
|
761
|
+
.foregroundColor: UIColor.label
|
|
762
|
+
]
|
|
763
|
+
textView.typingAttributes = plainAttributes
|
|
764
|
+
textView.insertText("\n\(nextNumber). ")
|
|
765
|
+
return false
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return true
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
func updateToolbarButtonStates() {
|
|
772
|
+
let range = textView.selectedRange
|
|
773
|
+
|
|
774
|
+
let text = textView.text ?? ""
|
|
775
|
+
let nsText = text as NSString
|
|
776
|
+
var lineStart = range.location
|
|
777
|
+
while lineStart > 0 && nsText.character(at: lineStart - 1) != 10 {
|
|
778
|
+
lineStart -= 1
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
var lineEnd = range.location
|
|
782
|
+
while lineEnd < text.count && nsText.character(at: lineEnd) != 10 {
|
|
783
|
+
lineEnd += 1
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
let lineContent = lineStart < lineEnd ? nsText.substring(with: NSRange(location: lineStart, length: lineEnd - lineStart)) : ""
|
|
787
|
+
|
|
788
|
+
let hasBullet = lineContent.hasPrefix("• ")
|
|
789
|
+
let hasNumbered = lineContent.range(of: "^\\d+\\.\\s", options: .regularExpression) != nil
|
|
790
|
+
let hasQuote = lineContent.hasPrefix("\"") && lineContent.hasSuffix("\"")
|
|
791
|
+
let hasChecklist = lineContent.hasPrefix("☐ ") || lineContent.hasPrefix("☑ ")
|
|
792
|
+
|
|
793
|
+
var currentAlignment: NSTextAlignment = .left
|
|
794
|
+
if let paragraphStyle = textView.attributedText?.attribute(.paragraphStyle, at: max(0, range.location - 1), effectiveRange: nil) as? NSParagraphStyle {
|
|
795
|
+
currentAlignment = paragraphStyle.alignment
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
guard range.length > 0 else {
|
|
799
|
+
floatingToolbar.updateButtonStates(
|
|
800
|
+
bold: false, italic: false, underline: false, strikethrough: false,
|
|
801
|
+
code: false, highlight: false, heading: false,
|
|
802
|
+
bullet: hasBullet, numbered: hasNumbered, quote: hasQuote, checklist: hasChecklist,
|
|
803
|
+
alignLeft: currentAlignment == .left, alignCenter: currentAlignment == .center, alignRight: currentAlignment == .right
|
|
804
|
+
)
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
let attributedText = textView.attributedText ?? NSAttributedString()
|
|
809
|
+
var hasBold = false
|
|
810
|
+
var hasItalic = false
|
|
811
|
+
var hasUnderline = false
|
|
812
|
+
var hasStrikethrough = false
|
|
813
|
+
var hasCode = false
|
|
814
|
+
var hasHighlight = false
|
|
815
|
+
var hasHeading = false
|
|
816
|
+
|
|
817
|
+
attributedText.enumerateAttributes(in: range, options: []) { attrs, _, _ in
|
|
818
|
+
if let font = attrs[.font] as? UIFont {
|
|
819
|
+
let traits = font.fontDescriptor.symbolicTraits
|
|
820
|
+
if traits.contains(.traitBold) { hasBold = true }
|
|
821
|
+
if traits.contains(.traitItalic) { hasItalic = true }
|
|
822
|
+
if traits.contains(.traitMonoSpace) { hasCode = true }
|
|
823
|
+
if font.pointSize > 18 { hasHeading = true }
|
|
824
|
+
}
|
|
825
|
+
if attrs[.underlineStyle] != nil { hasUnderline = true }
|
|
826
|
+
if attrs[.strikethroughStyle] != nil { hasStrikethrough = true }
|
|
827
|
+
if let bgColor = attrs[.backgroundColor] as? UIColor, bgColor != UIColor.systemGray5 {
|
|
828
|
+
hasHighlight = true
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
floatingToolbar.updateButtonStates(
|
|
833
|
+
bold: hasBold, italic: hasItalic, underline: hasUnderline, strikethrough: hasStrikethrough,
|
|
834
|
+
code: hasCode, highlight: hasHighlight, heading: hasHeading,
|
|
835
|
+
bullet: hasBullet, numbered: hasNumbered, quote: hasQuote, checklist: hasChecklist,
|
|
836
|
+
alignLeft: currentAlignment == .left, alignCenter: currentAlignment == .center, alignRight: currentAlignment == .right
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
@objc private func textDidChange() {
|
|
841
|
+
placeholderLabel.isHidden = !textView.text.isEmpty
|
|
842
|
+
|
|
843
|
+
if !isInternalChange {
|
|
844
|
+
saveToUndoStack()
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
applyListIndentation()
|
|
848
|
+
updateContentSize()
|
|
849
|
+
sendContentChange()
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private func updateContentSize() {
|
|
853
|
+
let width = bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width - 32
|
|
854
|
+
let fittingSize = textView.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
|
|
855
|
+
|
|
856
|
+
let minHeight: CGFloat = 44
|
|
857
|
+
var newHeight = max(fittingSize.height, minHeight)
|
|
858
|
+
|
|
859
|
+
if maxHeight > 0 {
|
|
860
|
+
textView.isScrollEnabled = newHeight > maxHeight
|
|
861
|
+
newHeight = min(newHeight, maxHeight)
|
|
862
|
+
} else {
|
|
863
|
+
textView.isScrollEnabled = false
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
calculatedHeight = newHeight
|
|
867
|
+
|
|
868
|
+
if abs(newHeight - lastReportedHeight) > 0.5 {
|
|
869
|
+
lastReportedHeight = newHeight
|
|
870
|
+
onSizeChange?(["height": newHeight])
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
invalidateIntrinsicContentSize()
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
override func layoutSubviews() {
|
|
877
|
+
super.layoutSubviews()
|
|
878
|
+
updateContentSize()
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
override var intrinsicContentSize: CGSize {
|
|
882
|
+
return CGSize(width: UIView.noIntrinsicMetric, height: calculatedHeight)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
886
|
+
let width = size.width > 0 ? size.width : bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width - 32
|
|
887
|
+
let fittingSize = textView.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
|
|
888
|
+
var height = max(fittingSize.height, 44)
|
|
889
|
+
|
|
890
|
+
if maxHeight > 0 {
|
|
891
|
+
height = min(height, maxHeight)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return CGSize(width: size.width, height: height)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private func applyVariantStyle() {
|
|
898
|
+
if variant == "flat" {
|
|
899
|
+
layer.borderWidth = 0
|
|
900
|
+
layer.cornerRadius = 0
|
|
901
|
+
bottomBorder.isHidden = false
|
|
902
|
+
} else {
|
|
903
|
+
layer.borderColor = UIColor.separator.cgColor
|
|
904
|
+
layer.borderWidth = 1
|
|
905
|
+
layer.cornerRadius = 8
|
|
906
|
+
bottomBorder.isHidden = true
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private func applyListIndentation() {
|
|
911
|
+
guard let text = textView.text, !text.isEmpty else { return }
|
|
912
|
+
|
|
913
|
+
let mutableAttrString = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
914
|
+
let nsText = text as NSString
|
|
915
|
+
|
|
916
|
+
let font = UIFont.systemFont(ofSize: 16)
|
|
917
|
+
let bulletPrefix = "• "
|
|
918
|
+
let bulletWidth = (bulletPrefix as NSString).size(withAttributes: [.font: font]).width
|
|
919
|
+
|
|
920
|
+
let numberedPattern = "^(\\d+)\\.\\s"
|
|
921
|
+
let regex = try? NSRegularExpression(pattern: numberedPattern, options: [])
|
|
922
|
+
|
|
923
|
+
var lineStart = 0
|
|
924
|
+
while lineStart < text.count {
|
|
925
|
+
var lineEnd = lineStart
|
|
926
|
+
while lineEnd < text.count && nsText.character(at: lineEnd) != 10 {
|
|
927
|
+
lineEnd += 1
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
let lineRange = NSRange(location: lineStart, length: lineEnd - lineStart)
|
|
931
|
+
let lineText = nsText.substring(with: lineRange)
|
|
932
|
+
|
|
933
|
+
let paragraphStyle = NSMutableParagraphStyle()
|
|
934
|
+
paragraphStyle.alignment = .left
|
|
935
|
+
|
|
936
|
+
if lineText.hasPrefix("• ") {
|
|
937
|
+
paragraphStyle.firstLineHeadIndent = 0
|
|
938
|
+
paragraphStyle.headIndent = bulletWidth
|
|
939
|
+
} else if let match = regex?.firstMatch(in: lineText, range: NSRange(location: 0, length: lineText.count)) {
|
|
940
|
+
let matchedPrefix = (lineText as NSString).substring(with: match.range)
|
|
941
|
+
let prefixWidth = (matchedPrefix as NSString).size(withAttributes: [.font: font]).width
|
|
942
|
+
paragraphStyle.firstLineHeadIndent = 0
|
|
943
|
+
paragraphStyle.headIndent = prefixWidth
|
|
944
|
+
} else {
|
|
945
|
+
paragraphStyle.firstLineHeadIndent = 0
|
|
946
|
+
paragraphStyle.headIndent = 0
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
mutableAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: lineRange)
|
|
950
|
+
|
|
951
|
+
lineStart = lineEnd + 1
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
let selectedRange = textView.selectedRange
|
|
955
|
+
|
|
956
|
+
isInternalChange = true
|
|
957
|
+
textView.attributedText = mutableAttrString
|
|
958
|
+
textView.selectedRange = selectedRange
|
|
959
|
+
isInternalChange = false
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private func saveToUndoStack() {
|
|
963
|
+
undoStack.append(textView.attributedText)
|
|
964
|
+
if undoStack.count > 50 {
|
|
965
|
+
undoStack.removeFirst()
|
|
966
|
+
}
|
|
967
|
+
redoStack.removeAll()
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
private func sendContentChange() {
|
|
971
|
+
let blocks = getBlocksArray()
|
|
972
|
+
onContentChange?([
|
|
973
|
+
"text": textView.text ?? "",
|
|
974
|
+
"blocks": blocks
|
|
975
|
+
])
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
func toggleBold() {
|
|
979
|
+
toggleStyle(key: .font, trait: .traitBold)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
func toggleItalic() {
|
|
983
|
+
toggleStyle(key: .font, trait: .traitItalic)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
func toggleUnderline() {
|
|
987
|
+
toggleAttribute(key: .underlineStyle, value: NSUnderlineStyle.single.rawValue)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
func toggleStrikethrough() {
|
|
991
|
+
toggleAttribute(key: .strikethroughStyle, value: NSUnderlineStyle.single.rawValue)
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
func toggleCode() {
|
|
995
|
+
let range = textView.selectedRange
|
|
996
|
+
guard range.length > 0 else { return }
|
|
997
|
+
|
|
998
|
+
let mutableAttrString = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
999
|
+
var hasMonospace = false
|
|
1000
|
+
|
|
1001
|
+
mutableAttrString.enumerateAttribute(.font, in: range, options: []) { value, _, _ in
|
|
1002
|
+
if let font = value as? UIFont {
|
|
1003
|
+
hasMonospace = font.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
let monoFont = UIFont(name: "Menlo", size: 16) ?? UIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
|
|
1008
|
+
let regularFont = UIFont.systemFont(ofSize: 16)
|
|
1009
|
+
|
|
1010
|
+
mutableAttrString.enumerateAttribute(.font, in: range, options: []) { value, attrRange, _ in
|
|
1011
|
+
let newFont = hasMonospace ? regularFont : monoFont
|
|
1012
|
+
mutableAttrString.addAttribute(.font, value: newFont, range: attrRange)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if !hasMonospace {
|
|
1016
|
+
mutableAttrString.addAttribute(.backgroundColor, value: UIColor.systemGray5, range: range)
|
|
1017
|
+
} else {
|
|
1018
|
+
mutableAttrString.removeAttribute(.backgroundColor, range: range)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
isInternalChange = true
|
|
1022
|
+
textView.attributedText = mutableAttrString
|
|
1023
|
+
textView.selectedRange = range
|
|
1024
|
+
isInternalChange = false
|
|
1025
|
+
saveToUndoStack()
|
|
1026
|
+
sendContentChange()
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
func toggleHighlight(color: String?) {
|
|
1030
|
+
let range = textView.selectedRange
|
|
1031
|
+
guard range.length > 0 else { return }
|
|
1032
|
+
|
|
1033
|
+
let mutableAttrString = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
1034
|
+
var hasHighlight = false
|
|
1035
|
+
|
|
1036
|
+
mutableAttrString.enumerateAttribute(.backgroundColor, in: range, options: []) { value, _, _ in
|
|
1037
|
+
if let bgColor = value as? UIColor, bgColor != UIColor.systemGray5 {
|
|
1038
|
+
hasHighlight = true
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if hasHighlight {
|
|
1043
|
+
mutableAttrString.removeAttribute(.backgroundColor, range: range)
|
|
1044
|
+
} else {
|
|
1045
|
+
let highlightColor = UIColor.yellow.withAlphaComponent(0.5)
|
|
1046
|
+
mutableAttrString.addAttribute(.backgroundColor, value: highlightColor, range: range)
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
isInternalChange = true
|
|
1050
|
+
textView.attributedText = mutableAttrString
|
|
1051
|
+
textView.selectedRange = range
|
|
1052
|
+
isInternalChange = false
|
|
1053
|
+
saveToUndoStack()
|
|
1054
|
+
sendContentChange()
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
func setHeading() {
|
|
1058
|
+
let range = textView.selectedRange
|
|
1059
|
+
let text = textView.text ?? ""
|
|
1060
|
+
let nsText = text as NSString
|
|
1061
|
+
|
|
1062
|
+
var lineStart = range.location
|
|
1063
|
+
while lineStart > 0 && nsText.character(at: lineStart - 1) != 10 {
|
|
1064
|
+
lineStart -= 1
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
var lineEnd = range.location
|
|
1068
|
+
while lineEnd < text.count && nsText.character(at: lineEnd) != 10 {
|
|
1069
|
+
lineEnd += 1
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
let lineRange = NSRange(location: lineStart, length: lineEnd - lineStart)
|
|
1073
|
+
|
|
1074
|
+
let mutableAttrString = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
1075
|
+
|
|
1076
|
+
var isHeading = false
|
|
1077
|
+
if lineRange.length > 0 {
|
|
1078
|
+
mutableAttrString.enumerateAttribute(.font, in: lineRange, options: []) { value, _, _ in
|
|
1079
|
+
if let font = value as? UIFont, font.pointSize > 18 {
|
|
1080
|
+
isHeading = true
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
let headingFont = UIFont.boldSystemFont(ofSize: 24)
|
|
1086
|
+
let regularFont = UIFont.systemFont(ofSize: 16)
|
|
1087
|
+
|
|
1088
|
+
mutableAttrString.addAttribute(.font, value: isHeading ? regularFont : headingFont, range: lineRange)
|
|
1089
|
+
|
|
1090
|
+
isInternalChange = true
|
|
1091
|
+
textView.attributedText = mutableAttrString
|
|
1092
|
+
textView.selectedRange = range
|
|
1093
|
+
isInternalChange = false
|
|
1094
|
+
saveToUndoStack()
|
|
1095
|
+
sendContentChange()
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
func setQuote() {
|
|
1099
|
+
// Implementation for quote
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
func setChecklist() {
|
|
1103
|
+
// Implementation for checklist
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
func toggleChecklistItem() {
|
|
1107
|
+
// Implementation for toggle checklist item
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
func setParagraph() {
|
|
1111
|
+
// Implementation for paragraph
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
func clearFormatting() {
|
|
1115
|
+
let range = textView.selectedRange
|
|
1116
|
+
guard range.length > 0 else { return }
|
|
1117
|
+
|
|
1118
|
+
let mutableAttrString = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
1119
|
+
let plainText = (textView.text as NSString?)?.substring(with: range) ?? ""
|
|
1120
|
+
|
|
1121
|
+
let plainAttributes: [NSAttributedString.Key: Any] = [
|
|
1122
|
+
.font: UIFont.systemFont(ofSize: 16),
|
|
1123
|
+
.foregroundColor: UIColor.label
|
|
1124
|
+
]
|
|
1125
|
+
|
|
1126
|
+
mutableAttrString.replaceCharacters(in: range, with: NSAttributedString(string: plainText, attributes: plainAttributes))
|
|
1127
|
+
|
|
1128
|
+
isInternalChange = true
|
|
1129
|
+
textView.attributedText = mutableAttrString
|
|
1130
|
+
textView.selectedRange = range
|
|
1131
|
+
isInternalChange = false
|
|
1132
|
+
saveToUndoStack()
|
|
1133
|
+
sendContentChange()
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
func indent() {
|
|
1137
|
+
// Implementation for indent
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
func outdent() {
|
|
1141
|
+
// Implementation for outdent
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
func setAlignment(_ alignment: NSTextAlignment) {
|
|
1145
|
+
let range = textView.selectedRange
|
|
1146
|
+
let text = textView.text ?? ""
|
|
1147
|
+
let nsText = text as NSString
|
|
1148
|
+
|
|
1149
|
+
var lineStart = range.location
|
|
1150
|
+
while lineStart > 0 && nsText.character(at: lineStart - 1) != 10 {
|
|
1151
|
+
lineStart -= 1
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
var lineEnd = range.location + range.length
|
|
1155
|
+
while lineEnd < text.count && nsText.character(at: lineEnd) != 10 {
|
|
1156
|
+
lineEnd += 1
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
let lineRange = NSRange(location: lineStart, length: lineEnd - lineStart)
|
|
1160
|
+
|
|
1161
|
+
let mutableAttrString = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
1162
|
+
let paragraphStyle = NSMutableParagraphStyle()
|
|
1163
|
+
paragraphStyle.alignment = alignment
|
|
1164
|
+
|
|
1165
|
+
mutableAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: lineRange)
|
|
1166
|
+
|
|
1167
|
+
isInternalChange = true
|
|
1168
|
+
textView.attributedText = mutableAttrString
|
|
1169
|
+
textView.selectedRange = range
|
|
1170
|
+
isInternalChange = false
|
|
1171
|
+
saveToUndoStack()
|
|
1172
|
+
sendContentChange()
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
func promptInsertLink() {
|
|
1176
|
+
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
|
1177
|
+
let viewController = windowScene.windows.first?.rootViewController else {
|
|
1178
|
+
return
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
let alert = UIAlertController(title: "Insert Link", message: nil, preferredStyle: .alert)
|
|
1182
|
+
alert.addTextField { textField in
|
|
1183
|
+
textField.placeholder = "Link text"
|
|
1184
|
+
if let selectedText = self.textView.text(in: self.textView.selectedTextRange ?? UITextRange()) {
|
|
1185
|
+
textField.text = selectedText
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
alert.addTextField { textField in
|
|
1189
|
+
textField.placeholder = "URL"
|
|
1190
|
+
textField.keyboardType = .URL
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
1194
|
+
alert.addAction(UIAlertAction(title: "Insert", style: .default) { [weak self] _ in
|
|
1195
|
+
guard let text = alert.textFields?[0].text,
|
|
1196
|
+
let url = alert.textFields?[1].text,
|
|
1197
|
+
!text.isEmpty, !url.isEmpty else { return }
|
|
1198
|
+
self?.insertLink(url: url, text: text)
|
|
1199
|
+
})
|
|
1200
|
+
|
|
1201
|
+
viewController.present(alert, animated: true)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
private func toggleStyle(key: NSAttributedString.Key, trait: UIFontDescriptor.SymbolicTraits) {
|
|
1205
|
+
let range = textView.selectedRange
|
|
1206
|
+
guard range.length > 0 else { return }
|
|
1207
|
+
|
|
1208
|
+
let mutableAttrString = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
1209
|
+
var hasTrait = false
|
|
1210
|
+
|
|
1211
|
+
mutableAttrString.enumerateAttribute(.font, in: range, options: []) { value, _, _ in
|
|
1212
|
+
if let font = value as? UIFont {
|
|
1213
|
+
hasTrait = font.fontDescriptor.symbolicTraits.contains(trait)
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
mutableAttrString.enumerateAttribute(.font, in: range, options: []) { value, attrRange, _ in
|
|
1218
|
+
if let font = value as? UIFont {
|
|
1219
|
+
var newTraits = font.fontDescriptor.symbolicTraits
|
|
1220
|
+
if hasTrait {
|
|
1221
|
+
newTraits.remove(trait)
|
|
1222
|
+
} else {
|
|
1223
|
+
newTraits.insert(trait)
|
|
1224
|
+
}
|
|
1225
|
+
if let descriptor = font.fontDescriptor.withSymbolicTraits(newTraits) {
|
|
1226
|
+
let newFont = UIFont(descriptor: descriptor, size: font.pointSize)
|
|
1227
|
+
mutableAttrString.addAttribute(.font, value: newFont, range: attrRange)
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
isInternalChange = true
|
|
1233
|
+
textView.attributedText = mutableAttrString
|
|
1234
|
+
textView.selectedRange = range
|
|
1235
|
+
isInternalChange = false
|
|
1236
|
+
saveToUndoStack()
|
|
1237
|
+
sendContentChange()
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
private func toggleAttribute(key: NSAttributedString.Key, value: Int) {
|
|
1241
|
+
let range = textView.selectedRange
|
|
1242
|
+
guard range.length > 0 else { return }
|
|
1243
|
+
|
|
1244
|
+
let mutableAttrString = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
1245
|
+
var hasAttribute = false
|
|
1246
|
+
|
|
1247
|
+
mutableAttrString.enumerateAttribute(key, in: range, options: []) { attrValue, _, _ in
|
|
1248
|
+
if attrValue != nil {
|
|
1249
|
+
hasAttribute = true
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if hasAttribute {
|
|
1254
|
+
mutableAttrString.removeAttribute(key, range: range)
|
|
1255
|
+
} else {
|
|
1256
|
+
mutableAttrString.addAttribute(key, value: value, range: range)
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
isInternalChange = true
|
|
1260
|
+
textView.attributedText = mutableAttrString
|
|
1261
|
+
textView.selectedRange = range
|
|
1262
|
+
isInternalChange = false
|
|
1263
|
+
saveToUndoStack()
|
|
1264
|
+
sendContentChange()
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
func toggleBulletList() {
|
|
1268
|
+
toggleListStyle(bullet: true)
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
func toggleNumberedList() {
|
|
1272
|
+
toggleListStyle(bullet: false)
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
private func toggleListStyle(bullet: Bool) {
|
|
1276
|
+
let range = textView.selectedRange
|
|
1277
|
+
let text = textView.text ?? ""
|
|
1278
|
+
let nsText = text as NSString
|
|
1279
|
+
|
|
1280
|
+
var selectionStart = range.location
|
|
1281
|
+
var selectionEnd = range.location + range.length
|
|
1282
|
+
|
|
1283
|
+
while selectionStart > 0 && nsText.character(at: selectionStart - 1) != 10 {
|
|
1284
|
+
selectionStart -= 1
|
|
1285
|
+
}
|
|
1286
|
+
while selectionEnd < text.count && nsText.character(at: selectionEnd) != 10 {
|
|
1287
|
+
selectionEnd += 1
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
let selectedText = nsText.substring(with: NSRange(location: selectionStart, length: selectionEnd - selectionStart))
|
|
1291
|
+
let lines = selectedText.components(separatedBy: "\n")
|
|
1292
|
+
|
|
1293
|
+
let bulletPrefix = "• "
|
|
1294
|
+
let numberedPattern = "^\\d+\\.\\s"
|
|
1295
|
+
let regex = try? NSRegularExpression(pattern: numberedPattern)
|
|
1296
|
+
|
|
1297
|
+
let allHavePrefix: Bool
|
|
1298
|
+
if bullet {
|
|
1299
|
+
allHavePrefix = lines.allSatisfy { $0.hasPrefix(bulletPrefix) || $0.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
1300
|
+
} else {
|
|
1301
|
+
allHavePrefix = lines.allSatisfy { line in
|
|
1302
|
+
if line.trimmingCharacters(in: .whitespaces).isEmpty { return true }
|
|
1303
|
+
return regex?.firstMatch(in: line, range: NSRange(location: 0, length: line.count)) != nil
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
var newLines: [String] = []
|
|
1308
|
+
for (index, line) in lines.enumerated() {
|
|
1309
|
+
if line.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
1310
|
+
newLines.append(line)
|
|
1311
|
+
continue
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if allHavePrefix {
|
|
1315
|
+
if bullet && line.hasPrefix(bulletPrefix) {
|
|
1316
|
+
newLines.append(String(line.dropFirst(bulletPrefix.count)))
|
|
1317
|
+
} else if !bullet, let match = regex?.firstMatch(in: line, range: NSRange(location: 0, length: line.count)) {
|
|
1318
|
+
newLines.append(String(line.dropFirst(match.range.length)))
|
|
1319
|
+
} else {
|
|
1320
|
+
newLines.append(line)
|
|
1321
|
+
}
|
|
1322
|
+
} else {
|
|
1323
|
+
var cleanLine = line
|
|
1324
|
+
if line.hasPrefix(bulletPrefix) {
|
|
1325
|
+
cleanLine = String(line.dropFirst(bulletPrefix.count))
|
|
1326
|
+
} else if let match = regex?.firstMatch(in: line, range: NSRange(location: 0, length: line.count)) {
|
|
1327
|
+
cleanLine = String(line.dropFirst(match.range.length))
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if bullet {
|
|
1331
|
+
newLines.append(bulletPrefix + cleanLine)
|
|
1332
|
+
} else {
|
|
1333
|
+
newLines.append("\(index + 1). " + cleanLine)
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
let newText = newLines.joined(separator: "\n")
|
|
1339
|
+
let mutableText = NSMutableString(string: text)
|
|
1340
|
+
mutableText.replaceCharacters(in: NSRange(location: selectionStart, length: selectionEnd - selectionStart), with: newText)
|
|
1341
|
+
|
|
1342
|
+
isInternalChange = true
|
|
1343
|
+
textView.text = mutableText as String
|
|
1344
|
+
textView.selectedRange = NSRange(location: selectionStart, length: newText.count)
|
|
1345
|
+
isInternalChange = false
|
|
1346
|
+
|
|
1347
|
+
applyListIndentation()
|
|
1348
|
+
saveToUndoStack()
|
|
1349
|
+
sendContentChange()
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
func setContent(blocks: [[String: Any]]) {
|
|
1353
|
+
let attributedString = NSMutableAttributedString()
|
|
1354
|
+
let font = UIFont.systemFont(ofSize: 16)
|
|
1355
|
+
|
|
1356
|
+
var numberedIndex = 1
|
|
1357
|
+
for (blockIndex, block) in blocks.enumerated() {
|
|
1358
|
+
guard let text = block["text"] as? String else { continue }
|
|
1359
|
+
let blockType = block["type"] as? String ?? "paragraph"
|
|
1360
|
+
|
|
1361
|
+
var displayText = text
|
|
1362
|
+
var prefixLength = 0
|
|
1363
|
+
let paragraphStyle = NSMutableParagraphStyle()
|
|
1364
|
+
paragraphStyle.alignment = .left
|
|
1365
|
+
|
|
1366
|
+
switch blockType {
|
|
1367
|
+
case "bullet":
|
|
1368
|
+
let bulletPrefix = "• "
|
|
1369
|
+
displayText = bulletPrefix + text
|
|
1370
|
+
prefixLength = 2
|
|
1371
|
+
let bulletWidth = (bulletPrefix as NSString).size(withAttributes: [.font: font]).width
|
|
1372
|
+
paragraphStyle.firstLineHeadIndent = 0
|
|
1373
|
+
paragraphStyle.headIndent = bulletWidth
|
|
1374
|
+
case "numbered":
|
|
1375
|
+
let prefix = "\(numberedIndex). "
|
|
1376
|
+
displayText = prefix + text
|
|
1377
|
+
prefixLength = prefix.count
|
|
1378
|
+
let prefixWidth = (prefix as NSString).size(withAttributes: [.font: font]).width
|
|
1379
|
+
paragraphStyle.firstLineHeadIndent = 0
|
|
1380
|
+
paragraphStyle.headIndent = prefixWidth
|
|
1381
|
+
numberedIndex += 1
|
|
1382
|
+
default:
|
|
1383
|
+
paragraphStyle.firstLineHeadIndent = 0
|
|
1384
|
+
paragraphStyle.headIndent = 0
|
|
1385
|
+
numberedIndex = 1
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
let blockAttrString = NSMutableAttributedString(string: displayText, attributes: [
|
|
1389
|
+
.font: font,
|
|
1390
|
+
.foregroundColor: UIColor.label,
|
|
1391
|
+
.paragraphStyle: paragraphStyle
|
|
1392
|
+
])
|
|
1393
|
+
|
|
1394
|
+
if let styles = block["styles"] as? [[String: Any]] {
|
|
1395
|
+
for style in styles {
|
|
1396
|
+
guard let start = style["start"] as? Int,
|
|
1397
|
+
let end = style["end"] as? Int,
|
|
1398
|
+
let styleType = style["style"] as? String,
|
|
1399
|
+
start < end && end <= text.count else { continue }
|
|
1400
|
+
|
|
1401
|
+
let range = NSRange(location: start + prefixLength, length: end - start)
|
|
1402
|
+
|
|
1403
|
+
switch styleType {
|
|
1404
|
+
case "bold":
|
|
1405
|
+
let boldFont = UIFont.boldSystemFont(ofSize: font.pointSize)
|
|
1406
|
+
blockAttrString.addAttribute(.font, value: boldFont, range: range)
|
|
1407
|
+
case "italic":
|
|
1408
|
+
let italicFont = UIFont.italicSystemFont(ofSize: font.pointSize)
|
|
1409
|
+
blockAttrString.addAttribute(.font, value: italicFont, range: range)
|
|
1410
|
+
case "underline":
|
|
1411
|
+
blockAttrString.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
|
|
1412
|
+
case "strikethrough":
|
|
1413
|
+
blockAttrString.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range)
|
|
1414
|
+
default:
|
|
1415
|
+
break
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if blockIndex < blocks.count - 1 {
|
|
1421
|
+
blockAttrString.append(NSAttributedString(string: "\n", attributes: [
|
|
1422
|
+
.font: font,
|
|
1423
|
+
.foregroundColor: UIColor.label,
|
|
1424
|
+
.paragraphStyle: paragraphStyle
|
|
1425
|
+
]))
|
|
1426
|
+
}
|
|
1427
|
+
attributedString.append(blockAttrString)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
isInternalChange = true
|
|
1431
|
+
textView.attributedText = attributedString
|
|
1432
|
+
placeholderLabel.isHidden = !textView.text.isEmpty
|
|
1433
|
+
isInternalChange = false
|
|
1434
|
+
applyListIndentation()
|
|
1435
|
+
|
|
1436
|
+
let endPosition = textView.text?.count ?? 0
|
|
1437
|
+
textView.selectedRange = NSRange(location: endPosition, length: 0)
|
|
1438
|
+
|
|
1439
|
+
DispatchQueue.main.async { [weak self] in
|
|
1440
|
+
self?.textView.scrollRangeToVisible(NSRange(location: endPosition, length: 0))
|
|
1441
|
+
self?.updateContentSize()
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
func getText() -> String {
|
|
1446
|
+
return textView.text ?? ""
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
func getBlocksArray() -> [[String: Any]] {
|
|
1450
|
+
let text = textView.text ?? ""
|
|
1451
|
+
let attributedText = textView.attributedText ?? NSAttributedString()
|
|
1452
|
+
|
|
1453
|
+
let lines = text.components(separatedBy: "\n")
|
|
1454
|
+
var blocks: [[String: Any]] = []
|
|
1455
|
+
var currentIndex = 0
|
|
1456
|
+
let numberedPattern = "^(\\d+)\\.\\s"
|
|
1457
|
+
let regex = try? NSRegularExpression(pattern: numberedPattern, options: [])
|
|
1458
|
+
|
|
1459
|
+
for line in lines {
|
|
1460
|
+
var blockType = "paragraph"
|
|
1461
|
+
var displayText = line
|
|
1462
|
+
|
|
1463
|
+
if line.hasPrefix("• ") {
|
|
1464
|
+
blockType = "bullet"
|
|
1465
|
+
displayText = String(line.dropFirst(2))
|
|
1466
|
+
} else if let match = regex?.firstMatch(in: line, range: NSRange(location: 0, length: line.count)) {
|
|
1467
|
+
blockType = "numbered"
|
|
1468
|
+
displayText = String(line.dropFirst(match.range.length))
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
var styles: [[String: Any]] = []
|
|
1472
|
+
let lineRange = NSRange(location: currentIndex, length: line.count)
|
|
1473
|
+
|
|
1474
|
+
if lineRange.location + lineRange.length <= attributedText.length {
|
|
1475
|
+
attributedText.enumerateAttributes(in: lineRange, options: []) { attrs, range, _ in
|
|
1476
|
+
let relativeStart = range.location - currentIndex
|
|
1477
|
+
let relativeEnd = relativeStart + range.length
|
|
1478
|
+
|
|
1479
|
+
if let font = attrs[.font] as? UIFont {
|
|
1480
|
+
let traits = font.fontDescriptor.symbolicTraits
|
|
1481
|
+
if traits.contains(.traitBold) {
|
|
1482
|
+
styles.append(["style": "bold", "start": relativeStart, "end": relativeEnd])
|
|
1483
|
+
}
|
|
1484
|
+
if traits.contains(.traitItalic) {
|
|
1485
|
+
styles.append(["style": "italic", "start": relativeStart, "end": relativeEnd])
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
if attrs[.underlineStyle] != nil {
|
|
1489
|
+
styles.append(["style": "underline", "start": relativeStart, "end": relativeEnd])
|
|
1490
|
+
}
|
|
1491
|
+
if attrs[.strikethroughStyle] != nil {
|
|
1492
|
+
styles.append(["style": "strikethrough", "start": relativeStart, "end": relativeEnd])
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
blocks.append([
|
|
1498
|
+
"type": blockType,
|
|
1499
|
+
"text": displayText,
|
|
1500
|
+
"styles": styles
|
|
1501
|
+
])
|
|
1502
|
+
|
|
1503
|
+
currentIndex += line.count + 1
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
return blocks
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
func clear() {
|
|
1510
|
+
isInternalChange = true
|
|
1511
|
+
textView.text = ""
|
|
1512
|
+
textView.attributedText = NSAttributedString()
|
|
1513
|
+
placeholderLabel.isHidden = false
|
|
1514
|
+
isInternalChange = false
|
|
1515
|
+
sendContentChange()
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
func focus() {
|
|
1519
|
+
textView.becomeFirstResponder()
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
func blur() {
|
|
1523
|
+
textView.resignFirstResponder()
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
func insertLink(url: String, text: String) {
|
|
1527
|
+
let range = textView.selectedRange
|
|
1528
|
+
let mutableAttrString = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
1529
|
+
|
|
1530
|
+
let linkAttrString = NSAttributedString(string: text, attributes: [
|
|
1531
|
+
.link: url,
|
|
1532
|
+
.foregroundColor: UIColor.systemBlue,
|
|
1533
|
+
.underlineStyle: NSUnderlineStyle.single.rawValue
|
|
1534
|
+
])
|
|
1535
|
+
|
|
1536
|
+
if range.length > 0 {
|
|
1537
|
+
mutableAttrString.replaceCharacters(in: range, with: linkAttrString)
|
|
1538
|
+
} else {
|
|
1539
|
+
mutableAttrString.insert(linkAttrString, at: range.location)
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
isInternalChange = true
|
|
1543
|
+
textView.attributedText = mutableAttrString
|
|
1544
|
+
isInternalChange = false
|
|
1545
|
+
saveToUndoStack()
|
|
1546
|
+
sendContentChange()
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
func undo() {
|
|
1550
|
+
guard undoStack.count > 1 else { return }
|
|
1551
|
+
|
|
1552
|
+
let current = undoStack.removeLast()
|
|
1553
|
+
redoStack.append(current)
|
|
1554
|
+
|
|
1555
|
+
if let previous = undoStack.last {
|
|
1556
|
+
isInternalChange = true
|
|
1557
|
+
textView.attributedText = previous
|
|
1558
|
+
placeholderLabel.isHidden = !textView.text.isEmpty
|
|
1559
|
+
isInternalChange = false
|
|
1560
|
+
sendContentChange()
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
func redo() {
|
|
1565
|
+
guard let next = redoStack.popLast() else { return }
|
|
1566
|
+
|
|
1567
|
+
undoStack.append(next)
|
|
1568
|
+
isInternalChange = true
|
|
1569
|
+
textView.attributedText = next
|
|
1570
|
+
placeholderLabel.isHidden = !textView.text.isEmpty
|
|
1571
|
+
isInternalChange = false
|
|
1572
|
+
sendContentChange()
|
|
1573
|
+
}
|
|
1574
|
+
}
|