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