@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.
@@ -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
+ }