@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,566 @@
1
+ import Foundation
2
+ import UIKit
3
+ import Lynx
4
+
5
+ /// Native UI for the `<sigx-richtext>` JSX element — an attributed-text input.
6
+ ///
7
+ /// Registered via the autolinker (`signalx-module.json` → `ios.uiComponents`).
8
+ ///
9
+ /// Prop surface (v1): `value` (initial-only JSON RichDoc), `placeholder`,
10
+ /// `editable`, `min-height`, `max-height`, `font-size`, `text-color`,
11
+ /// `accent-color`, `placeholder-color`, `confirm-type`, `auto-focus`.
12
+ ///
13
+ /// Events: `bindchange` (full RichDoc readback + isComposing),
14
+ /// `bindselection` (range + active formats + caret rect),
15
+ /// `bindheightchange`, `bindfocus`, `bindblur`.
16
+ ///
17
+ /// UI methods: `setDocument`, `getDocument`, `toggleFormat`, `setBlockType`,
18
+ /// `insertText`, `setSelectionRange`, `focus`, `blur`.
19
+ ///
20
+ /// ### IME / echo contract (mirrors lynx-runtime's input `setValue` rules)
21
+ /// 1. Every user edit bumps `localVersion`; `bindchange` carries it inside the doc.
22
+ /// 2. `setDocument` with a structurally-identical doc is a silent no-op.
23
+ /// 3. `setDocument` with `v < localVersion` is dropped; current state is re-emitted.
24
+ /// 4. `setDocument` during an active IME composition is dropped (re-emit) —
25
+ /// replacing the storage mid-composition corrupts CJK/emoji input.
26
+ // Not `@objc` at class level — LynxUI is an ObjC lightweight generic (see
27
+ // SigxWebViewUI for the long-form rationale); member-level @objc still bridges.
28
+ public class SigxRichTextUI: LynxUI<RichTextView> {
29
+
30
+ private static let kUIMethodSuccess: Int32 = 0
31
+ private static let kUIMethodUnknown: Int32 = 1
32
+
33
+ private var theme = RichTextTheme()
34
+ private var localVersion = 0
35
+ private var userHasEdited = false
36
+ private var minHeight: CGFloat = 0
37
+ private var maxHeight: CGFloat = 0
38
+ private var lastReportedHeight: CGFloat = -1
39
+ /// Guards delegate re-entry while this class mutates the storage itself.
40
+ fileprivate var isProgrammaticEdit = false
41
+ /// Last non-collapsed selection — toolbar taps can collapse the live
42
+ /// selection before the command invoke arrives; format commands fall
43
+ /// back to this (cleared whenever the text mutates).
44
+ fileprivate var lastNonCollapsedSelection: NSRange? = nil
45
+
46
+ private lazy var textDelegate = SigxRichTextDelegate(owner: self)
47
+
48
+ // MARK: - LynxUI overrides
49
+
50
+ public override func createView() -> RichTextView? {
51
+ let view = RichTextView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), textContainer: nil)
52
+ view.delegate = textDelegate
53
+ view.backgroundColor = .clear
54
+ view.isScrollEnabled = true
55
+ view.alwaysBounceVertical = false
56
+ view.font = theme.baseFont
57
+ view.textColor = theme.textColor
58
+ view.tintColor = theme.accentColor
59
+ // Tight insets — the JS side owns outer padding via styles.
60
+ view.textContainerInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
61
+ return view
62
+ }
63
+
64
+ // MARK: - Prop registration
65
+
66
+ @objc public class func propSetterLookUp() -> NSArray {
67
+ return [
68
+ ["value", "setValue:requestReset:"],
69
+ ["placeholder", "setPlaceholder:requestReset:"],
70
+ ["editable", "setEditable:requestReset:"],
71
+ ["min-height", "setMinHeight:requestReset:"],
72
+ ["max-height", "setMaxHeight:requestReset:"],
73
+ ["font-size", "setFontSize:requestReset:"],
74
+ ["text-color", "setTextColor:requestReset:"],
75
+ ["accent-color", "setAccentColor:requestReset:"],
76
+ ["placeholder-color", "setPlaceholderColor:requestReset:"],
77
+ ["confirm-type", "setConfirmType:requestReset:"],
78
+ ["auto-focus", "setAutoFocus:requestReset:"],
79
+ ] as NSArray
80
+ }
81
+
82
+ // MARK: - Prop setters
83
+
84
+ /// Initial document. Initial-only once the user has edited — programmatic
85
+ /// replacements go through the `setDocument` UI method (same contract as
86
+ /// the stock input's `value` + `setValue`).
87
+ @objc public func setValue(_ value: NSString?, requestReset: Bool) {
88
+ guard !userHasEdited, let json = value as String?, !json.isEmpty else { return }
89
+ guard let (parsed, version) = DocumentMapper.parse(json: json, theme: theme) else { return }
90
+ isProgrammaticEdit = true
91
+ view().attributedText = parsed
92
+ isProgrammaticEdit = false
93
+ localVersion = version
94
+ reportHeightIfChanged()
95
+ }
96
+
97
+ @objc public func setPlaceholder(_ value: NSString?, requestReset: Bool) {
98
+ view().placeholderText = (value as String?) ?? ""
99
+ }
100
+
101
+ // Primitive props arrive as NSNumber: LynxPropsProcessor's propSetterLookUp
102
+ // path derives the arg type from ObjC argument 0 (= self, type "@"), so the
103
+ // value is always delivered as an object — a primitive Bool/CGFloat param
104
+ // slot would be filled with pointer bits (observed: editable=true -> 0).
105
+ @objc public func setEditable(_ value: NSNumber?, requestReset: Bool) {
106
+ let editable = value?.boolValue ?? true
107
+ view().isEditable = editable
108
+ view().isSelectable = true
109
+ }
110
+
111
+ @objc public func setMinHeight(_ value: NSNumber?, requestReset: Bool) {
112
+ minHeight = CGFloat(value?.doubleValue ?? 0)
113
+ reportHeightIfChanged()
114
+ }
115
+
116
+ @objc public func setMaxHeight(_ value: NSNumber?, requestReset: Bool) {
117
+ maxHeight = CGFloat(value?.doubleValue ?? 0)
118
+ reportHeightIfChanged()
119
+ }
120
+
121
+ @objc public func setFontSize(_ value: NSNumber?, requestReset: Bool) {
122
+ let size = CGFloat(value?.doubleValue ?? 0)
123
+ guard size > 0 else { return }
124
+ theme.fontSize = size
125
+ view().font = theme.baseFont
126
+ refreshAllVisuals()
127
+ }
128
+
129
+ @objc public func setTextColor(_ value: NSString?, requestReset: Bool) {
130
+ guard let color = UIColor.sigxColor(hex: value as String?) else { return }
131
+ theme.textColor = color
132
+ view().textColor = color
133
+ refreshAllVisuals()
134
+ }
135
+
136
+ @objc public func setAccentColor(_ value: NSString?, requestReset: Bool) {
137
+ guard let color = UIColor.sigxColor(hex: value as String?) else { return }
138
+ theme.accentColor = color
139
+ view().tintColor = color
140
+ refreshAllVisuals()
141
+ }
142
+
143
+ @objc public func setPlaceholderColor(_ value: NSString?, requestReset: Bool) {
144
+ guard let color = UIColor.sigxColor(hex: value as String?) else { return }
145
+ theme.placeholderColor = color
146
+ view().placeholderColor = color
147
+ }
148
+
149
+ @objc public func setConfirmType(_ value: NSString?, requestReset: Bool) {
150
+ switch (value as String?) ?? "" {
151
+ case "send": view().returnKeyType = .send
152
+ case "search": view().returnKeyType = .search
153
+ case "next": view().returnKeyType = .next
154
+ case "go": view().returnKeyType = .go
155
+ case "done": view().returnKeyType = .done
156
+ default: view().returnKeyType = .default
157
+ }
158
+ }
159
+
160
+ @objc public func setAutoFocus(_ value: NSNumber?, requestReset: Bool) {
161
+ guard value?.boolValue == true else { return }
162
+ DispatchQueue.main.async { self.view().becomeFirstResponder() }
163
+ }
164
+
165
+ // Per-prop __lynx_prop_config__ discovery shims (kept alongside
166
+ // propSetterLookUp for parity with SigxWebViewUI).
167
+ @objc(__lynx_prop_config__value)
168
+ public class func __lynxPropConfigValue() -> [String] { ["value", "setValue", "NSString *"] }
169
+ @objc(__lynx_prop_config__placeholder)
170
+ public class func __lynxPropConfigPlaceholder() -> [String] { ["placeholder", "setPlaceholder", "NSString *"] }
171
+ @objc(__lynx_prop_config__editable)
172
+ public class func __lynxPropConfigEditable() -> [String] { ["editable", "setEditable", "NSNumber *"] }
173
+ @objc(__lynx_prop_config__min_height)
174
+ public class func __lynxPropConfigMinHeight() -> [String] { ["min-height", "setMinHeight", "NSNumber *"] }
175
+ @objc(__lynx_prop_config__max_height)
176
+ public class func __lynxPropConfigMaxHeight() -> [String] { ["max-height", "setMaxHeight", "NSNumber *"] }
177
+ @objc(__lynx_prop_config__font_size)
178
+ public class func __lynxPropConfigFontSize() -> [String] { ["font-size", "setFontSize", "NSNumber *"] }
179
+ @objc(__lynx_prop_config__text_color)
180
+ public class func __lynxPropConfigTextColor() -> [String] { ["text-color", "setTextColor", "NSString *"] }
181
+ @objc(__lynx_prop_config__accent_color)
182
+ public class func __lynxPropConfigAccentColor() -> [String] { ["accent-color", "setAccentColor", "NSString *"] }
183
+ @objc(__lynx_prop_config__placeholder_color)
184
+ public class func __lynxPropConfigPlaceholderColor() -> [String] { ["placeholder-color", "setPlaceholderColor", "NSString *"] }
185
+ @objc(__lynx_prop_config__confirm_type)
186
+ public class func __lynxPropConfigConfirmType() -> [String] { ["confirm-type", "setConfirmType", "NSString *"] }
187
+ @objc(__lynx_prop_config__auto_focus)
188
+ public class func __lynxPropConfigAutoFocus() -> [String] { ["auto-focus", "setAutoFocus", "NSNumber *"] }
189
+
190
+ // MARK: - UI methods
191
+
192
+ @objc public func setDocument(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
193
+ let json = (params?["doc"] as? String) ?? ""
194
+ DispatchQueue.main.async {
195
+ let view = self.view()
196
+ // Rule 4: never replace the storage mid-composition.
197
+ if view.markedTextRange != nil {
198
+ self.fireChange(isComposing: true)
199
+ callback(SigxRichTextUI.kUIMethodSuccess, ["applied": false, "reason": "composing"])
200
+ return
201
+ }
202
+ guard let (parsed, version) = DocumentMapper.parse(json: json, theme: self.theme) else {
203
+ callback(SigxRichTextUI.kUIMethodUnknown, "setDocument: unparseable doc")
204
+ return
205
+ }
206
+ // Rule 3: drop stale writes, re-emit so JS reconciles.
207
+ if version < self.localVersion {
208
+ self.fireChange(isComposing: false)
209
+ callback(SigxRichTextUI.kUIMethodSuccess, ["applied": false, "reason": "stale"])
210
+ return
211
+ }
212
+ // Rule 2: structural no-op suppression.
213
+ if parsed.isEqual(view.attributedText) {
214
+ self.localVersion = max(self.localVersion, version)
215
+ callback(SigxRichTextUI.kUIMethodSuccess, ["applied": false, "reason": "equal"])
216
+ return
217
+ }
218
+ let caret = view.selectedRange
219
+ self.isProgrammaticEdit = true
220
+ view.attributedText = parsed
221
+ self.isProgrammaticEdit = false
222
+ // The document has diverged from the initial `value` prop — lock
223
+ // the prop out (initial-only contract), same as a user edit.
224
+ self.userHasEdited = true
225
+ self.localVersion = max(self.localVersion, version)
226
+ // Preserve the caret position, clamped to the new length.
227
+ let upper = (parsed.string as NSString).length
228
+ view.selectedRange = NSRange(location: min(caret.location, upper), length: 0)
229
+ self.reportHeightIfChanged()
230
+ self.fireChange(isComposing: false)
231
+ callback(SigxRichTextUI.kUIMethodSuccess, ["applied": true])
232
+ }
233
+ }
234
+ @objc(__lynx_ui_method_config__setDocument)
235
+ dynamic public class func __lynxUIMethodConfigSetDocument() -> NSString { return "setDocument" }
236
+
237
+ @objc public func getDocument(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
238
+ DispatchQueue.main.async {
239
+ let json = DocumentMapper.encode(self.view().attributedText ?? NSAttributedString(), version: self.localVersion)
240
+ callback(SigxRichTextUI.kUIMethodSuccess, ["doc": json])
241
+ }
242
+ }
243
+ @objc(__lynx_ui_method_config__getDocument)
244
+ dynamic public class func __lynxUIMethodConfigGetDocument() -> NSString { return "getDocument" }
245
+
246
+ @objc public func toggleFormat(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
247
+ let type = (params?["type"] as? String) ?? ""
248
+ guard let key = SigxRichTextUI.inlineKey(for: type) else {
249
+ callback(SigxRichTextUI.kUIMethodUnknown, "toggleFormat: unknown type \(type)")
250
+ return
251
+ }
252
+ DispatchQueue.main.async {
253
+ let view = self.view()
254
+ var selection = view.selectedRange
255
+ // Toolbar taps can collapse the selection (focus shifts) before this
256
+ // invoke arrives — fall back to the last real selection.
257
+ if selection.length == 0, let last = self.lastNonCollapsedSelection,
258
+ last.location + last.length <= (view.text as NSString).length {
259
+ selection = last
260
+ }
261
+ if selection.length == 0 {
262
+ // Collapsed: flip the typing attributes so the next typed run
263
+ // carries (or drops) the format.
264
+ var typing = view.typingAttributes
265
+ let active = typing[key] != nil
266
+ if active { typing.removeValue(forKey: key) } else { typing[key] = true }
267
+ typing[.font] = SigxRichTextUI.deriveTypingFont(from: typing, theme: self.theme)
268
+ view.typingAttributes = typing
269
+ self.fireSelection()
270
+ callback(SigxRichTextUI.kUIMethodSuccess, ["active": !active])
271
+ return
272
+ }
273
+ guard let storage = view.textStorage as NSTextStorage? else {
274
+ callback(SigxRichTextUI.kUIMethodUnknown, "toggleFormat: no storage")
275
+ return
276
+ }
277
+ let active = SigxRichTextUI.rangeFullyHasAttribute(storage, key: key, range: selection)
278
+ self.isProgrammaticEdit = true
279
+ storage.beginEditing()
280
+ if active {
281
+ storage.removeAttribute(key, range: selection)
282
+ } else {
283
+ storage.addAttribute(key, value: true, range: selection)
284
+ }
285
+ DocumentMapper.refreshVisuals(storage, range: selection, theme: self.theme)
286
+ storage.endEditing()
287
+ self.isProgrammaticEdit = false
288
+ self.userHasEdited = true
289
+ self.localVersion += 1
290
+ // Restore the (possibly fallen-back) selection and keep the field
291
+ // focused so consecutive toolbar taps compose naturally.
292
+ view.selectedRange = selection
293
+ if !view.isFirstResponder { view.becomeFirstResponder() }
294
+ self.fireChange(isComposing: false)
295
+ self.fireSelection()
296
+ callback(SigxRichTextUI.kUIMethodSuccess, ["active": !active])
297
+ }
298
+ }
299
+ @objc(__lynx_ui_method_config__toggleFormat)
300
+ dynamic public class func __lynxUIMethodConfigToggleFormat() -> NSString { return "toggleFormat" }
301
+
302
+ @objc public func setBlockType(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
303
+ let type = (params?["type"] as? String) ?? "paragraph"
304
+ let level = params?["level"] as? Int
305
+ DispatchQueue.main.async {
306
+ let view = self.view()
307
+ guard let storage = view.textStorage as NSTextStorage? else {
308
+ callback(SigxRichTextUI.kUIMethodUnknown, "setBlockType: no storage")
309
+ return
310
+ }
311
+ let ns = storage.string as NSString
312
+ let paragraph = ns.paragraphRange(for: view.selectedRange)
313
+ self.isProgrammaticEdit = true
314
+ storage.beginEditing()
315
+ if type == "paragraph" {
316
+ storage.removeAttribute(SigxAttr.block, range: paragraph)
317
+ } else {
318
+ var value: [String: Any] = ["type": type]
319
+ if let level { value["level"] = level }
320
+ storage.addAttribute(SigxAttr.block, value: value, range: paragraph)
321
+ }
322
+ DocumentMapper.refreshVisuals(storage, range: paragraph, theme: self.theme)
323
+ storage.endEditing()
324
+ self.isProgrammaticEdit = false
325
+ self.userHasEdited = true
326
+ self.localVersion += 1
327
+ self.reportHeightIfChanged()
328
+ self.fireChange(isComposing: false)
329
+ self.fireSelection()
330
+ callback(SigxRichTextUI.kUIMethodSuccess, nil)
331
+ }
332
+ }
333
+ @objc(__lynx_ui_method_config__setBlockType)
334
+ dynamic public class func __lynxUIMethodConfigSetBlockType() -> NSString { return "setBlockType" }
335
+
336
+ @objc public func insertText(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
337
+ let text = (params?["text"] as? String) ?? ""
338
+ guard !text.isEmpty else {
339
+ callback(SigxRichTextUI.kUIMethodSuccess, nil)
340
+ return
341
+ }
342
+ DispatchQueue.main.async {
343
+ let view = self.view()
344
+ if view.markedTextRange != nil {
345
+ callback(SigxRichTextUI.kUIMethodSuccess, ["applied": false, "reason": "composing"])
346
+ return
347
+ }
348
+ self.isProgrammaticEdit = true
349
+ view.insertText(text) // inherits typingAttributes
350
+ self.isProgrammaticEdit = false
351
+ self.userHasEdited = true
352
+ self.localVersion += 1
353
+ self.reportHeightIfChanged()
354
+ self.fireChange(isComposing: false)
355
+ callback(SigxRichTextUI.kUIMethodSuccess, ["applied": true])
356
+ }
357
+ }
358
+ @objc(__lynx_ui_method_config__insertText)
359
+ dynamic public class func __lynxUIMethodConfigInsertText() -> NSString { return "insertText" }
360
+
361
+ @objc public func setSelectionRange(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
362
+ let start = (params?["start"] as? Int) ?? 0
363
+ let end = (params?["end"] as? Int) ?? start
364
+ DispatchQueue.main.async {
365
+ let view = self.view()
366
+ let upper = (view.text as NSString).length
367
+ let s = max(0, min(start, upper))
368
+ let e = max(s, min(end, upper))
369
+ view.selectedRange = NSRange(location: s, length: e - s)
370
+ callback(SigxRichTextUI.kUIMethodSuccess, nil)
371
+ }
372
+ }
373
+ @objc(__lynx_ui_method_config__setSelectionRange)
374
+ dynamic public class func __lynxUIMethodConfigSetSelectionRange() -> NSString { return "setSelectionRange" }
375
+
376
+ @objc public func focus(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
377
+ DispatchQueue.main.async {
378
+ self.view().becomeFirstResponder()
379
+ callback(SigxRichTextUI.kUIMethodSuccess, nil)
380
+ }
381
+ }
382
+ @objc(__lynx_ui_method_config__focus)
383
+ dynamic public class func __lynxUIMethodConfigFocus() -> NSString { return "focus" }
384
+
385
+ @objc public func blur(_ params: NSDictionary?, withResult callback: @escaping LynxUIMethodCallbackBlock) {
386
+ DispatchQueue.main.async {
387
+ self.view().resignFirstResponder()
388
+ callback(SigxRichTextUI.kUIMethodSuccess, nil)
389
+ }
390
+ }
391
+ @objc(__lynx_ui_method_config__blur)
392
+ dynamic public class func __lynxUIMethodConfigBlur() -> NSString { return "blur" }
393
+
394
+ // MARK: - Event firing (shared with the delegate)
395
+
396
+ func fireEvent(_ name: String, params: [String: Any]) {
397
+ let event = LynxCustomEvent(name: name, targetSign: sign, params: params)
398
+ context?.eventEmitter?.send(event)
399
+ }
400
+
401
+ func fireChange(isComposing: Bool) {
402
+ let json = DocumentMapper.encode(view().attributedText ?? NSAttributedString(), version: localVersion)
403
+ fireEvent("change", params: ["doc": json, "isComposing": isComposing])
404
+ }
405
+
406
+ func fireSelection() {
407
+ let view = self.view()
408
+ let range = view.selectedRange
409
+ var formats: [String] = []
410
+ let attrs = SigxRichTextUI.attributesForSelection(view)
411
+ for (key, name) in SigxAttr.inlineKeys where attrs[key] != nil { formats.append(name) }
412
+ if attrs[SigxAttr.link] != nil { formats.append("link") }
413
+
414
+ var activeBlock = "paragraph"
415
+ var headingLevel: Int? = nil
416
+ if let block = attrs[SigxAttr.block] as? [String: Any], let type = block["type"] as? String {
417
+ activeBlock = type
418
+ headingLevel = block["level"] as? Int
419
+ }
420
+
421
+ var caret = CGRect.zero
422
+ if let position = view.selectedTextRange?.end {
423
+ caret = view.caretRect(for: position)
424
+ }
425
+
426
+ var params: [String: Any] = [
427
+ "start": range.location,
428
+ "end": range.location + range.length,
429
+ "activeFormats": formats.joined(separator: ","),
430
+ "activeBlock": activeBlock,
431
+ "caretX": caret.origin.x.isFinite ? caret.origin.x : 0,
432
+ "caretY": caret.origin.y.isFinite ? caret.origin.y : 0,
433
+ "caretHeight": caret.height.isFinite ? caret.height : 0,
434
+ ]
435
+ if let headingLevel { params["headingLevel"] = headingLevel }
436
+ fireEvent("selection", params: params)
437
+ }
438
+
439
+ func markUserEdited() {
440
+ userHasEdited = true
441
+ localVersion += 1
442
+ }
443
+
444
+ func reportHeightIfChanged() {
445
+ let view = self.view()
446
+ let content = view.contentHeight()
447
+ // Internal scrolling only once content exceeds the ceiling.
448
+ if maxHeight > 0 {
449
+ view.isScrollEnabled = content > maxHeight
450
+ }
451
+ let clamped = max(minHeight, maxHeight > 0 ? min(content, maxHeight) : content)
452
+ if abs(clamped - lastReportedHeight) >= 0.5 {
453
+ lastReportedHeight = clamped
454
+ fireEvent("heightchange", params: ["height": clamped, "lines": view.lineCount()])
455
+ }
456
+ }
457
+
458
+ // MARK: - Helpers
459
+
460
+ private func refreshAllVisuals() {
461
+ guard let storage = view().textStorage as NSTextStorage?, storage.length > 0 else { return }
462
+ isProgrammaticEdit = true
463
+ storage.beginEditing()
464
+ DocumentMapper.refreshVisuals(storage, range: NSRange(location: 0, length: storage.length), theme: theme)
465
+ storage.endEditing()
466
+ isProgrammaticEdit = false
467
+ }
468
+
469
+ private static func inlineKey(for type: String) -> NSAttributedString.Key? {
470
+ switch type {
471
+ case "bold": return SigxAttr.bold
472
+ case "italic": return SigxAttr.italic
473
+ case "strike": return SigxAttr.strike
474
+ case "code": return SigxAttr.code
475
+ default: return nil
476
+ }
477
+ }
478
+
479
+ private static func rangeFullyHasAttribute(_ storage: NSAttributedString, key: NSAttributedString.Key, range: NSRange) -> Bool {
480
+ guard range.length > 0 else { return false }
481
+ var covered = 0
482
+ storage.enumerateAttribute(key, in: range, options: []) { value, sub, _ in
483
+ if value != nil { covered += sub.length }
484
+ }
485
+ return covered == range.length
486
+ }
487
+
488
+ /// Attributes representing the current selection: typing attributes when
489
+ /// collapsed (caret), the attributes at the selection start otherwise.
490
+ private static func attributesForSelection(_ view: UITextView) -> [NSAttributedString.Key: Any] {
491
+ let range = view.selectedRange
492
+ if range.length == 0 { return view.typingAttributes }
493
+ guard let storage = view.attributedText, storage.length > 0,
494
+ range.location < storage.length else { return view.typingAttributes }
495
+ return storage.attributes(at: range.location, effectiveRange: nil)
496
+ }
497
+
498
+ /// Rebuild the `.font` typing attribute from the custom model keys so a
499
+ /// collapsed-selection toggle is visible on the very next typed character.
500
+ fileprivate static func deriveTypingFont(from attrs: [NSAttributedString.Key: Any], theme: RichTextTheme) -> UIFont {
501
+ var font = theme.baseFont
502
+ if let block = attrs[SigxAttr.block] as? [String: Any],
503
+ (block["type"] as? String) == "heading" {
504
+ font = theme.headingFont(level: block["level"] as? Int ?? 1)
505
+ }
506
+ if attrs[SigxAttr.code] != nil { font = theme.codeFont }
507
+ var traits = font.fontDescriptor.symbolicTraits
508
+ if attrs[SigxAttr.bold] != nil { traits.insert(.traitBold) }
509
+ if attrs[SigxAttr.italic] != nil { traits.insert(.traitItalic) }
510
+ if traits != font.fontDescriptor.symbolicTraits,
511
+ let descriptor = font.fontDescriptor.withSymbolicTraits(traits) {
512
+ font = UIFont(descriptor: descriptor, size: font.pointSize)
513
+ }
514
+ return font
515
+ }
516
+ }
517
+
518
+ /// `UITextViewDelegate` adapter — separate object so the generic LynxUI
519
+ /// subclass doesn't need protocol conformances (mirrors SigxWebView's
520
+ /// delegate split).
521
+ final class SigxRichTextDelegate: NSObject, UITextViewDelegate {
522
+ private weak var owner: SigxRichTextUI?
523
+
524
+ init(owner: SigxRichTextUI) { self.owner = owner }
525
+
526
+ func textViewDidChange(_ textView: UITextView) {
527
+ guard let owner, !owner.isProgrammaticEdit else { return }
528
+ owner.lastNonCollapsedSelection = nil
529
+ owner.markUserEdited()
530
+ owner.reportHeightIfChanged()
531
+ let composing = textView.markedTextRange != nil
532
+ owner.fireChange(isComposing: composing)
533
+ }
534
+
535
+ func textViewDidChangeSelection(_ textView: UITextView) {
536
+ guard let owner, !owner.isProgrammaticEdit else { return }
537
+ if textView.selectedRange.length > 0 {
538
+ owner.lastNonCollapsedSelection = textView.selectedRange
539
+ }
540
+ owner.fireSelection()
541
+ }
542
+
543
+ func textViewDidBeginEditing(_ textView: UITextView) {
544
+ owner?.fireEvent("focus", params: [:])
545
+ }
546
+
547
+ func textViewDidEndEditing(_ textView: UITextView) {
548
+ owner?.fireEvent("blur", params: [:])
549
+ }
550
+ }
551
+
552
+ extension UIColor {
553
+ /// Parse `#RGB`, `#RRGGBB`, or `#RRGGBBAA` (leading `#` optional).
554
+ static func sigxColor(hex: String?) -> UIColor? {
555
+ guard var s = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty else { return nil }
556
+ if s.hasPrefix("#") { s.removeFirst() }
557
+ if s.count == 3 { s = s.map { "\($0)\($0)" }.joined() }
558
+ guard s.count == 6 || s.count == 8, let value = UInt64(s, radix: 16) else { return nil }
559
+ let hasAlpha = s.count == 8
560
+ let r = CGFloat((value >> (hasAlpha ? 24 : 16)) & 0xFF) / 255
561
+ let g = CGFloat((value >> (hasAlpha ? 16 : 8)) & 0xFF) / 255
562
+ let b = CGFloat((value >> (hasAlpha ? 8 : 0)) & 0xFF) / 255
563
+ let a = hasAlpha ? CGFloat(value & 0xFF) / 255 : 1
564
+ return UIColor(red: r, green: g, blue: b, alpha: a)
565
+ }
566
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@sigx/lynx-richtext",
3
+ "version": "0.4.7",
4
+ "description": "Native rich-text input element for Lynx (<sigx-richtext>): attributed editing (UITextView / EditText) with a span-based document model, selection events, and formatting commands. Powers @sigx/lynx-markdown's MarkdownEditor.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./signalx-module.json": "./signalx-module.json",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "ios",
20
+ "android",
21
+ "signalx-module.json",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "peerDependencies": {
26
+ "@sigx/lynx": "^0.4.7"
27
+ },
28
+ "devDependencies": {
29
+ "@typescript/native-preview": "7.0.0-dev.20260521.1",
30
+ "typescript": "^6.0.3",
31
+ "@sigx/lynx-testing": "^0.4.7",
32
+ "@sigx/lynx": "^0.4.7"
33
+ },
34
+ "author": "Andreas Ekdahl",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/signalxjs/lynx.git",
39
+ "directory": "packages/lynx-richtext"
40
+ },
41
+ "homepage": "https://github.com/signalxjs/lynx/tree/main/packages/lynx-richtext",
42
+ "bugs": {
43
+ "url": "https://github.com/signalxjs/lynx/issues"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "keywords": [
49
+ "signalx",
50
+ "sigx",
51
+ "lynx",
52
+ "rich-text",
53
+ "editor",
54
+ "input",
55
+ "wysiwyg"
56
+ ],
57
+ "scripts": {
58
+ "build": "node ../../scripts/clean.mjs dist && tsgo",
59
+ "dev": "tsgo --watch",
60
+ "test": "vitest run",
61
+ "clean": "node ../../scripts/clean.mjs dist .turbo"
62
+ }
63
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "RichText",
3
+ "package": "@sigx/lynx-richtext",
4
+ "description": "Native rich-text input element (<sigx-richtext>) — attributed editing via UITextView / EditText",
5
+ "platforms": ["android", "ios"],
6
+ "ios": {
7
+ "sourceDir": "ios",
8
+ "uiComponents": [
9
+ { "name": "sigx-richtext", "uiClass": "SigxRichTextUI" }
10
+ ]
11
+ },
12
+ "android": {
13
+ "sourceDir": "android",
14
+ "behaviors": [
15
+ { "name": "sigx-richtext", "behaviorClass": "com.sigx.richtext.SigxRichTextBehavior" }
16
+ ]
17
+ }
18
+ }