@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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/android/com/sigx/richtext/DocumentMapper.kt +148 -0
- package/android/com/sigx/richtext/RichEditText.kt +41 -0
- package/android/com/sigx/richtext/SigxRichTextBehavior.kt +18 -0
- package/android/com/sigx/richtext/SigxRichTextUI.kt +537 -0
- package/android/com/sigx/richtext/SigxSpans.kt +87 -0
- package/dist/RichTextInput.d.ts +20 -0
- package/dist/RichTextInput.js +41 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/jsx-augment.d.ts +48 -0
- package/dist/jsx-augment.js +9 -0
- package/dist/methods.d.ts +40 -0
- package/dist/methods.js +57 -0
- package/dist/model/codec.d.ts +23 -0
- package/dist/model/codec.js +123 -0
- package/dist/model/types.d.ts +113 -0
- package/dist/model/types.js +23 -0
- package/ios/DocumentMapper.swift +252 -0
- package/ios/RichTextView.swift +132 -0
- package/ios/SigxRichTextUI.swift +566 -0
- package/package.json +63 -0
- package/signalx-module.json +18 -0
|
@@ -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
|
+
}
|