@sigx/lynx-richtext 0.4.7

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,252 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ /// RichDoc (JSON) ↔ NSAttributedString mapping for `<sigx-richtext>`.
5
+ ///
6
+ /// ## Attribute scheme — custom attrs are the model, visuals are derived
7
+ ///
8
+ /// Every model fact is stored as an explicit custom attribute
9
+ /// (`sigx.bold`, `sigx.italic`, `sigx.strike`, `sigx.code`, `sigx.link`,
10
+ /// `sigx.block`) on the text storage. Visual attributes (`.font`,
11
+ /// `.strikethroughStyle`, `.foregroundColor`, `.backgroundColor`) are
12
+ /// recomputed *from* the custom attrs. Readback therefore consults only the
13
+ /// custom attrs, which makes it unambiguous: a heading's bold font never
14
+ /// reads back as a `bold` span, because only `sigx.bold` produces one.
15
+ ///
16
+ /// Custom attributes ride along with edits exactly like visual attributes
17
+ /// (UIKit moves attribute ranges on insert/delete and propagates them through
18
+ /// `typingAttributes`), which is what makes the "native storage is the source
19
+ /// of truth, JS reads it back" architecture work.
20
+ enum SigxAttr {
21
+ static let bold = NSAttributedString.Key("sigx.bold")
22
+ static let italic = NSAttributedString.Key("sigx.italic")
23
+ static let strike = NSAttributedString.Key("sigx.strike")
24
+ static let code = NSAttributedString.Key("sigx.code")
25
+ static let link = NSAttributedString.Key("sigx.link") // value: href String
26
+ static let mention = NSAttributedString.Key("sigx.mention") // value: [String:String] (P3)
27
+ static let block = NSAttributedString.Key("sigx.block") // value: [String:Any] {type, level?}
28
+
29
+ /// The inline model keys (paragraph-level `block` is handled separately).
30
+ static let inlineKeys: [(NSAttributedString.Key, String)] = [
31
+ (bold, "bold"), (italic, "italic"), (strike, "strike"), (code, "code"),
32
+ ]
33
+ }
34
+
35
+ struct RichTextTheme {
36
+ var fontSize: CGFloat = 16
37
+ var textColor: UIColor = .label
38
+ var accentColor: UIColor = .systemBlue
39
+ var placeholderColor: UIColor = .placeholderText
40
+
41
+ /// Heading scale per level (MVP renders 1–3; 4–6 fall back to 1.1×).
42
+ func headingFont(level: Int) -> UIFont {
43
+ let (scale, weight): (CGFloat, UIFont.Weight) = {
44
+ switch level {
45
+ case 1: return (1.75, .bold)
46
+ case 2: return (1.5, .bold)
47
+ case 3: return (1.25, .semibold)
48
+ default: return (1.1, .semibold)
49
+ }
50
+ }()
51
+ return .systemFont(ofSize: (fontSize * scale).rounded(), weight: weight)
52
+ }
53
+
54
+ var baseFont: UIFont { .systemFont(ofSize: fontSize) }
55
+ var codeFont: UIFont { .monospacedSystemFont(ofSize: (fontSize * 0.95).rounded(), weight: .regular) }
56
+ var codeBackground: UIColor { UIColor.secondarySystemFill }
57
+ }
58
+
59
+ enum DocumentMapper {
60
+
61
+ // MARK: - JSON → storage
62
+
63
+ /// Parse a JSON RichDoc and build the fully attributed string.
64
+ /// Returns nil for unparseable input (caller keeps current content).
65
+ static func parse(json: String, theme: RichTextTheme) -> (NSAttributedString, version: Int)? {
66
+ guard let data = json.data(using: .utf8),
67
+ let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
68
+ let text = obj["text"] as? String else { return nil }
69
+
70
+ let version = obj["v"] as? Int ?? 0
71
+ let result = NSMutableAttributedString(string: text)
72
+ let full = NSRange(location: 0, length: result.length)
73
+ let clampUpper = result.length
74
+
75
+ // Model attrs: inline spans.
76
+ if let spans = obj["spans"] as? [[String: Any]] {
77
+ for span in spans {
78
+ guard let start = span["start"] as? Int, let end = span["end"] as? Int,
79
+ let type = span["type"] as? String else { continue }
80
+ let s = max(0, min(start, clampUpper))
81
+ let e = max(s, min(end, clampUpper))
82
+ guard e > s else { continue }
83
+ let range = NSRange(location: s, length: e - s)
84
+ let attrs = span["attrs"] as? [String: String]
85
+ switch type {
86
+ case "bold": result.addAttribute(SigxAttr.bold, value: true, range: range)
87
+ case "italic": result.addAttribute(SigxAttr.italic, value: true, range: range)
88
+ case "strike": result.addAttribute(SigxAttr.strike, value: true, range: range)
89
+ case "code": result.addAttribute(SigxAttr.code, value: true, range: range)
90
+ case "link": result.addAttribute(SigxAttr.link, value: attrs?["href"] ?? "", range: range)
91
+ case "mention":
92
+ result.addAttribute(SigxAttr.mention, value: attrs ?? [:], range: range)
93
+ default: break
94
+ }
95
+ }
96
+ }
97
+
98
+ // Model attrs: blocks (snapped to paragraph ranges defensively).
99
+ if let blocks = obj["blocks"] as? [[String: Any]] {
100
+ let ns = result.string as NSString
101
+ for block in blocks {
102
+ guard let start = block["start"] as? Int, let end = block["end"] as? Int,
103
+ let type = block["type"] as? String, type != "paragraph" else { continue }
104
+ let s = max(0, min(start, clampUpper))
105
+ let e = max(s, min(end, clampUpper))
106
+ let snapped = ns.paragraphRange(for: NSRange(location: s, length: max(0, e - s)))
107
+ var value: [String: Any] = ["type": type]
108
+ if let level = block["level"] as? Int { value["level"] = level }
109
+ if let checked = block["checked"] as? Bool { value["checked"] = checked }
110
+ if snapped.length >= 0 { result.addAttribute(SigxAttr.block, value: value, range: snapped) }
111
+ }
112
+ }
113
+
114
+ refreshVisuals(result, range: full, theme: theme)
115
+ return (result, version)
116
+ }
117
+
118
+ // MARK: - Visual derivation
119
+
120
+ /// Recompute visual attributes from the custom model attrs over `range`.
121
+ static func refreshVisuals(_ storage: NSMutableAttributedString, range: NSRange, theme: RichTextTheme) {
122
+ guard range.length > 0 || storage.length == 0 else {
123
+ applyBaseVisuals(storage, range: range, theme: theme)
124
+ return
125
+ }
126
+ applyBaseVisuals(storage, range: range, theme: theme)
127
+ }
128
+
129
+ private static func applyBaseVisuals(_ storage: NSMutableAttributedString, range: NSRange, theme: RichTextTheme) {
130
+ guard range.location != NSNotFound, range.length > 0 else { return }
131
+
132
+ storage.enumerateAttributes(in: range, options: []) { attrs, sub, _ in
133
+ var font = theme.baseFont
134
+ var color = theme.textColor
135
+ var background: UIColor? = nil
136
+ var strike = false
137
+ var underline = false
138
+
139
+ // Block style first (heading fonts), then inline modifiers on top.
140
+ if let block = attrs[SigxAttr.block] as? [String: Any],
141
+ let type = block["type"] as? String, type == "heading" {
142
+ font = theme.headingFont(level: block["level"] as? Int ?? 1)
143
+ }
144
+
145
+ if attrs[SigxAttr.code] != nil {
146
+ font = theme.codeFont
147
+ background = theme.codeBackground
148
+ }
149
+
150
+ var traits = font.fontDescriptor.symbolicTraits
151
+ if attrs[SigxAttr.bold] != nil { traits.insert(.traitBold) }
152
+ if attrs[SigxAttr.italic] != nil { traits.insert(.traitItalic) }
153
+ if traits != font.fontDescriptor.symbolicTraits,
154
+ let descriptor = font.fontDescriptor.withSymbolicTraits(traits) {
155
+ font = UIFont(descriptor: descriptor, size: font.pointSize)
156
+ }
157
+
158
+ if attrs[SigxAttr.strike] != nil { strike = true }
159
+ if attrs[SigxAttr.link] != nil {
160
+ color = theme.accentColor
161
+ underline = true
162
+ }
163
+
164
+ storage.addAttribute(.font, value: font, range: sub)
165
+ storage.addAttribute(.foregroundColor, value: color, range: sub)
166
+ if let background {
167
+ storage.addAttribute(.backgroundColor, value: background, range: sub)
168
+ } else {
169
+ storage.removeAttribute(.backgroundColor, range: sub)
170
+ }
171
+ storage.addAttribute(
172
+ .strikethroughStyle,
173
+ value: strike ? NSUnderlineStyle.single.rawValue : 0,
174
+ range: sub
175
+ )
176
+ storage.addAttribute(
177
+ .underlineStyle,
178
+ value: underline ? NSUnderlineStyle.single.rawValue : 0,
179
+ range: sub
180
+ )
181
+ }
182
+ }
183
+
184
+ // MARK: - Storage → JSON (readback)
185
+
186
+ /// Read the model back out of the live storage. Native is authoritative
187
+ /// for live text — this runs after every edit to build event payloads.
188
+ static func encode(_ storage: NSAttributedString, version: Int) -> String {
189
+ let text = storage.string
190
+ var spans: [[String: Any]] = []
191
+ var blocks: [[String: Any]] = []
192
+ let full = NSRange(location: 0, length: storage.length)
193
+
194
+ if storage.length > 0 {
195
+ for (key, name) in SigxAttr.inlineKeys {
196
+ storage.enumerateAttribute(key, in: full, options: []) { value, range, _ in
197
+ guard value != nil else { return }
198
+ spans.append(["start": range.location, "end": range.location + range.length, "type": name])
199
+ }
200
+ }
201
+ storage.enumerateAttribute(SigxAttr.link, in: full, options: []) { value, range, _ in
202
+ guard let href = value as? String else { return }
203
+ spans.append([
204
+ "start": range.location, "end": range.location + range.length,
205
+ "type": "link", "attrs": ["href": href],
206
+ ])
207
+ }
208
+ storage.enumerateAttribute(SigxAttr.mention, in: full, options: []) { value, range, _ in
209
+ guard let attrs = value as? [String: String] else { return }
210
+ spans.append([
211
+ "start": range.location, "end": range.location + range.length,
212
+ "type": "mention", "attrs": attrs,
213
+ ])
214
+ }
215
+
216
+ // Blocks: walk paragraphs, take the block attr at each paragraph start.
217
+ let ns = text as NSString
218
+ var location = 0
219
+ while location < ns.length {
220
+ let para = ns.paragraphRange(for: NSRange(location: location, length: 0))
221
+ if let block = storage.attribute(SigxAttr.block, at: para.location, effectiveRange: nil) as? [String: Any],
222
+ let type = block["type"] as? String {
223
+ var entry: [String: Any] = [
224
+ "start": para.location,
225
+ "end": para.location + para.length,
226
+ "type": type,
227
+ ]
228
+ if let level = block["level"] as? Int { entry["level"] = level }
229
+ if let checked = block["checked"] as? Bool { entry["checked"] = checked }
230
+ blocks.append(entry)
231
+ }
232
+ if para.length == 0 { break }
233
+ location = para.location + para.length
234
+ }
235
+ }
236
+
237
+ spans.sort {
238
+ let a0 = $0["start"] as? Int ?? 0, b0 = $1["start"] as? Int ?? 0
239
+ if a0 != b0 { return a0 < b0 }
240
+ let a1 = $0["end"] as? Int ?? 0, b1 = $1["end"] as? Int ?? 0
241
+ if a1 != b1 { return a1 < b1 }
242
+ return ($0["type"] as? String ?? "") < ($1["type"] as? String ?? "")
243
+ }
244
+
245
+ let doc: [String: Any] = ["text": text, "spans": spans, "blocks": blocks, "v": version]
246
+ guard let data = try? JSONSerialization.data(withJSONObject: doc),
247
+ let json = String(data: data, encoding: .utf8) else {
248
+ return "{\"text\":\"\",\"spans\":[],\"blocks\":[],\"v\":\(version)}"
249
+ }
250
+ return json
251
+ }
252
+ }
@@ -0,0 +1,132 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ /// `UITextView` subclass backing `<sigx-richtext>`.
5
+ ///
6
+ /// Adds the pieces UITextView is missing for an input-style editor:
7
+ /// - a placeholder (label subview — custom `draw(_:)` is avoided on purpose:
8
+ /// it forces the TextKit-1 compatibility path on iOS 16+ and interferes
9
+ /// with the editing machinery),
10
+ /// - a tap-to-focus fallback recognizer (`cancelsTouchesInView = false`,
11
+ /// recognizes simultaneously) so focus works even when an ancestor
12
+ /// gesture system swallows the raw touch,
13
+ /// - intrinsic content-height reporting for auto-grow,
14
+ /// - hooks reserved for chip-aware deletion (P3).
15
+ public final class RichTextView: UITextView, UIGestureRecognizerDelegate {
16
+
17
+ private let placeholderLabel = UILabel()
18
+
19
+ public var placeholderText: String = "" {
20
+ didSet {
21
+ placeholderLabel.text = placeholderText
22
+ refreshPlaceholder()
23
+ }
24
+ }
25
+ public var placeholderColor: UIColor = .placeholderText {
26
+ didSet { placeholderLabel.textColor = placeholderColor }
27
+ }
28
+
29
+ public override var text: String! {
30
+ didSet { refreshPlaceholder() }
31
+ }
32
+
33
+ public override var attributedText: NSAttributedString! {
34
+ didSet { refreshPlaceholder() }
35
+ }
36
+
37
+ public override var font: UIFont? {
38
+ didSet { placeholderLabel.font = font }
39
+ }
40
+
41
+ public override init(frame: CGRect, textContainer: NSTextContainer?) {
42
+ super.init(frame: frame, textContainer: textContainer)
43
+
44
+ isEditable = true
45
+ isSelectable = true
46
+ isUserInteractionEnabled = true
47
+
48
+ placeholderLabel.textColor = placeholderColor
49
+ placeholderLabel.numberOfLines = 1
50
+ placeholderLabel.isUserInteractionEnabled = false
51
+ addSubview(placeholderLabel)
52
+
53
+ NotificationCenter.default.addObserver(
54
+ self,
55
+ selector: #selector(textDidChangeNotification),
56
+ name: UITextView.textDidChangeNotification,
57
+ object: self
58
+ )
59
+
60
+ // Focus fallback: Lynx's root gesture handling can swallow raw
61
+ // touches before UITextView's internal tap recognizers run. This
62
+ // recognizer runs alongside everything (`cancelsTouchesInView=false`,
63
+ // simultaneous with all) and only acts when the view isn't already
64
+ // first responder.
65
+ let tap = UITapGestureRecognizer(target: self, action: #selector(handleFocusTap(_:)))
66
+ tap.cancelsTouchesInView = false
67
+ tap.delegate = self
68
+ addGestureRecognizer(tap)
69
+ }
70
+
71
+ @available(*, unavailable)
72
+ public required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
73
+
74
+ deinit {
75
+ NotificationCenter.default.removeObserver(self)
76
+ }
77
+
78
+ public func gestureRecognizer(
79
+ _ gestureRecognizer: UIGestureRecognizer,
80
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
81
+ ) -> Bool {
82
+ return true
83
+ }
84
+
85
+ @objc private func handleFocusTap(_ recognizer: UITapGestureRecognizer) {
86
+ guard isEditable, !isFirstResponder else { return }
87
+ if becomeFirstResponder() {
88
+ // Place the caret at the tap location.
89
+ let point = recognizer.location(in: self)
90
+ if let position = closestPosition(to: point) {
91
+ selectedTextRange = textRange(from: position, to: position)
92
+ }
93
+ } else {
94
+ NSLog("[SigxRichText] becomeFirstResponder refused (editable=\(isEditable), window=\(window != nil))")
95
+ }
96
+ }
97
+
98
+ @objc private func textDidChangeNotification() {
99
+ refreshPlaceholder()
100
+ }
101
+
102
+ public override func layoutSubviews() {
103
+ super.layoutSubviews()
104
+ let x = textContainerInset.left + textContainer.lineFragmentPadding
105
+ let y = textContainerInset.top
106
+ let width = max(0, bounds.width - x * 2)
107
+ placeholderLabel.frame = CGRect(
108
+ x: x,
109
+ y: y,
110
+ width: width,
111
+ height: placeholderLabel.font?.lineHeight ?? 20
112
+ )
113
+ }
114
+
115
+ private func refreshPlaceholder() {
116
+ placeholderLabel.isHidden = !(text?.isEmpty ?? true) || placeholderText.isEmpty
117
+ }
118
+
119
+ /// Intrinsic content height for the current width (auto-grow reporting).
120
+ public func contentHeight() -> CGFloat {
121
+ let width = bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width
122
+ let size = sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
123
+ return size.height.rounded(.up)
124
+ }
125
+
126
+ /// Visible line count derived from layout.
127
+ public func lineCount() -> Int {
128
+ guard let font = font, font.lineHeight > 0 else { return 1 }
129
+ let textHeight = contentHeight() - textContainerInset.top - textContainerInset.bottom
130
+ return max(1, Int((textHeight / font.lineHeight).rounded()))
131
+ }
132
+ }