@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,537 @@
1
+ package com.sigx.richtext
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.text.Editable
7
+ import android.text.InputType
8
+ import android.text.Spannable
9
+ import android.text.TextWatcher
10
+ import android.util.TypedValue
11
+ import android.view.inputmethod.BaseInputConnection
12
+ import android.view.inputmethod.EditorInfo
13
+ import android.view.inputmethod.InputMethodManager
14
+ import com.lynx.react.bridge.Callback
15
+ import com.lynx.react.bridge.JavaOnlyMap
16
+ import com.lynx.react.bridge.ReadableMap
17
+ import com.lynx.tasm.behavior.LynxContext
18
+ import com.lynx.tasm.behavior.LynxProp
19
+ import com.lynx.tasm.behavior.LynxUIMethod
20
+ import com.lynx.tasm.behavior.LynxUIMethodConstants
21
+ import com.lynx.tasm.behavior.ui.LynxUI
22
+ import com.lynx.tasm.event.LynxDetailEvent
23
+
24
+ /**
25
+ * Native UI for the `<sigx-richtext>` JSX element on Android.
26
+ *
27
+ * See `SigxRichTextUI.swift` for the full prop/event/method contract and the
28
+ * IME/echo rules — the two implementations mirror each other 1:1. The model
29
+ * is carried by the sigx marker spans (`SigxSpans.kt`); native storage is
30
+ * authoritative after every edit and the model is read back from it.
31
+ */
32
+ class SigxRichTextUI(context: LynxContext) : LynxUI<RichEditText>(context) {
33
+
34
+ private val theme = RichTextTheme()
35
+ private var localVersion = 0
36
+ private var userHasEdited = false
37
+ private var minHeightPx = 0f
38
+ private var maxHeightPx = 0f
39
+ private var lastReportedHeight = -1f
40
+ private var isProgrammaticEdit = false
41
+
42
+ /**
43
+ * Collapsed-selection format toggles, pending until the next typed run —
44
+ * Android's analogue of iOS `typingAttributes` (true = force on,
45
+ * false = force off; absent = inherit via span flags).
46
+ */
47
+ private val typingOverrides = mutableMapOf<String, Boolean>()
48
+
49
+ private var pendingInsertStart = -1
50
+ private var pendingInsertEnd = -1
51
+
52
+ private val mainHandler = Handler(Looper.getMainLooper())
53
+
54
+ override fun createView(context: Context): RichEditText {
55
+ val view = RichEditText(context)
56
+ // Seed the theme from the view's platform-default color so derived
57
+ // visuals (code runs, etc.) match until `text-color` overrides it.
58
+ theme.textColor = view.currentTextColor
59
+ view.inputType = InputType.TYPE_CLASS_TEXT or
60
+ InputType.TYPE_TEXT_FLAG_MULTI_LINE or
61
+ InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
62
+ view.setHorizontallyScrolling(false)
63
+
64
+ view.addTextChangedListener(object : TextWatcher {
65
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
66
+
67
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
68
+ if (isProgrammaticEdit) return
69
+ if (count > 0) {
70
+ pendingInsertStart = start
71
+ pendingInsertEnd = start + count
72
+ } else {
73
+ pendingInsertStart = -1
74
+ pendingInsertEnd = -1
75
+ }
76
+ }
77
+
78
+ override fun afterTextChanged(s: Editable?) {
79
+ if (isProgrammaticEdit || s == null) return
80
+ applyTypingOverrides(s)
81
+ userHasEdited = true
82
+ localVersion += 1
83
+ reportHeightIfChanged()
84
+ fireChange(isComposing(s))
85
+ }
86
+ })
87
+
88
+ view.onSelectionChangedCallback = { start, end ->
89
+ if (!isProgrammaticEdit) {
90
+ // A real caret move ends any pending collapsed-toggle session
91
+ // — but the move caused by typing itself must not, so only
92
+ // clear when the caret lands outside the just-inserted run.
93
+ if (pendingInsertEnd < 0 || end != pendingInsertEnd) typingOverrides.clear()
94
+ fireSelection(start, end)
95
+ }
96
+ }
97
+ return view
98
+ }
99
+
100
+ // ── Prop setters ─────────────────────────────────────────────────────
101
+
102
+ /** Initial document; initial-only once the user has edited (see iOS). */
103
+ @LynxProp(name = "value")
104
+ fun setValue(value: String?) {
105
+ if (userHasEdited || value.isNullOrEmpty()) return
106
+ val parsed = DocumentMapper.parse(value, theme) ?: return
107
+ isProgrammaticEdit = true
108
+ mView.text = parsed.text
109
+ isProgrammaticEdit = false
110
+ localVersion = parsed.version
111
+ reportHeightIfChanged()
112
+ }
113
+
114
+ @LynxProp(name = "placeholder")
115
+ fun setPlaceholder(value: String?) {
116
+ mView.hint = value ?: ""
117
+ }
118
+
119
+ @LynxProp(name = "editable")
120
+ fun setEditable(value: Boolean) {
121
+ mView.isEnabled = value
122
+ mView.isFocusable = value
123
+ mView.isFocusableInTouchMode = value
124
+ }
125
+
126
+ @LynxProp(name = "min-height")
127
+ fun setMinHeight(value: Float) {
128
+ minHeightPx = value
129
+ reportHeightIfChanged()
130
+ }
131
+
132
+ @LynxProp(name = "max-height")
133
+ fun setMaxHeight(value: Float) {
134
+ maxHeightPx = value
135
+ reportHeightIfChanged()
136
+ }
137
+
138
+ @LynxProp(name = "font-size")
139
+ fun setEditorFontSize(value: Float) {
140
+ if (value <= 0f) return
141
+ theme.fontSizePx = value
142
+ mView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, value)
143
+ }
144
+
145
+ @LynxProp(name = "text-color")
146
+ fun setTextColor(value: String?) {
147
+ val color = parseColor(value) ?: return
148
+ theme.textColor = color
149
+ mView.setTextColor(color)
150
+ }
151
+
152
+ @LynxProp(name = "accent-color")
153
+ fun setAccentColor(value: String?) {
154
+ val color = parseColor(value) ?: return
155
+ theme.accentColor = color
156
+ }
157
+
158
+ @LynxProp(name = "placeholder-color")
159
+ fun setPlaceholderColor(value: String?) {
160
+ val color = parseColor(value) ?: return
161
+ mView.setHintTextColor(color)
162
+ }
163
+
164
+ @LynxProp(name = "confirm-type")
165
+ fun setConfirmType(value: String?) {
166
+ mView.imeOptions = when (value) {
167
+ "send" -> EditorInfo.IME_ACTION_SEND
168
+ "search" -> EditorInfo.IME_ACTION_SEARCH
169
+ "next" -> EditorInfo.IME_ACTION_NEXT
170
+ "go" -> EditorInfo.IME_ACTION_GO
171
+ "done" -> EditorInfo.IME_ACTION_DONE
172
+ else -> EditorInfo.IME_ACTION_UNSPECIFIED
173
+ }
174
+ }
175
+
176
+ @LynxProp(name = "auto-focus")
177
+ fun setAutoFocus(value: Boolean) {
178
+ if (!value) return
179
+ mainHandler.post { focusAndShowIme() }
180
+ }
181
+
182
+ // ── UI methods ───────────────────────────────────────────────────────
183
+
184
+ @LynxUIMethod
185
+ fun setDocument(params: ReadableMap?, callback: Callback?) {
186
+ val json = params?.getString("doc") ?: ""
187
+ mainHandler.post {
188
+ val editable = mView.text
189
+ // Rule 4: never replace storage mid-composition.
190
+ if (isComposing(editable)) {
191
+ fireChange(isComposing = true)
192
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("applied" to false, "reason" to "composing"))
193
+ return@post
194
+ }
195
+ val parsed = DocumentMapper.parse(json, theme)
196
+ if (parsed == null) {
197
+ callback?.invoke(LynxUIMethodConstants.UNKNOWN, "setDocument: unparseable doc")
198
+ return@post
199
+ }
200
+ // Rule 3: drop stale writes; re-emit so JS reconciles.
201
+ if (parsed.version < localVersion) {
202
+ fireChange(isComposing = false)
203
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("applied" to false, "reason" to "stale"))
204
+ return@post
205
+ }
206
+ // Rule 2: structural no-op suppression (compare canonical readback).
207
+ val incoming = DocumentMapper.encode(parsed.text, 0)
208
+ val current = DocumentMapper.encode(editable, 0)
209
+ if (incoming == current) {
210
+ localVersion = maxOf(localVersion, parsed.version)
211
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("applied" to false, "reason" to "equal"))
212
+ return@post
213
+ }
214
+ val caret = mView.selectionStart
215
+ isProgrammaticEdit = true
216
+ mView.text = parsed.text
217
+ isProgrammaticEdit = false
218
+ // The document has diverged from the initial `value` prop — lock
219
+ // the prop out (initial-only contract), same as a user edit.
220
+ userHasEdited = true
221
+ localVersion = maxOf(localVersion, parsed.version)
222
+ mView.setSelection(caret.coerceIn(0, mView.text.length))
223
+ reportHeightIfChanged()
224
+ fireChange(isComposing = false)
225
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("applied" to true))
226
+ }
227
+ }
228
+
229
+ @LynxUIMethod
230
+ fun getDocument(params: ReadableMap?, callback: Callback?) {
231
+ mainHandler.post {
232
+ val json = DocumentMapper.encode(mView.text, localVersion)
233
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("doc" to json))
234
+ }
235
+ }
236
+
237
+ @LynxUIMethod
238
+ fun toggleFormat(params: ReadableMap?, callback: Callback?) {
239
+ val type = params?.getString("type") ?: ""
240
+ if (markerClass(type) == null) {
241
+ callback?.invoke(LynxUIMethodConstants.UNKNOWN, "toggleFormat: unknown type $type")
242
+ return
243
+ }
244
+ mainHandler.post {
245
+ val editable = mView.text
246
+ val start = minOf(mView.selectionStart, mView.selectionEnd).coerceAtLeast(0)
247
+ val end = maxOf(mView.selectionStart, mView.selectionEnd).coerceAtLeast(0)
248
+ if (start == end) {
249
+ // Collapsed: tri-state typing override (Android's typingAttributes).
250
+ val inherited = formatActiveAt(editable, start, type)
251
+ val current = typingOverrides[type] ?: inherited
252
+ typingOverrides[type] = !current
253
+ fireSelection(start, end)
254
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("active" to !current))
255
+ return@post
256
+ }
257
+ val active = rangeFullyHas(editable, type, start, end)
258
+ isProgrammaticEdit = true
259
+ if (active) {
260
+ removeFormatRange(editable, type, start, end)
261
+ } else {
262
+ editable.setSpan(newMarker(type), start, end, DocumentMapper.INLINE_FLAGS)
263
+ }
264
+ isProgrammaticEdit = false
265
+ userHasEdited = true
266
+ localVersion += 1
267
+ mView.invalidate()
268
+ fireChange(isComposing = false)
269
+ fireSelection(start, end)
270
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("active" to !active))
271
+ }
272
+ }
273
+
274
+ @LynxUIMethod
275
+ fun setBlockType(params: ReadableMap?, callback: Callback?) {
276
+ val type = params?.getString("type") ?: "paragraph"
277
+ val level = if (params?.hasKey("level") == true) params.getInt("level") else 0
278
+ mainHandler.post {
279
+ val editable = mView.text
280
+ val caretStart = minOf(mView.selectionStart, mView.selectionEnd).coerceAtLeast(0)
281
+ val caretEnd = maxOf(mView.selectionStart, mView.selectionEnd).coerceAtLeast(0)
282
+ val (pStart, pEnd) = DocumentMapper.snapToParagraph(editable, caretStart, caretEnd)
283
+ isProgrammaticEdit = true
284
+ for (span in editable.getSpans(pStart, pEnd, SigxBlockSpan::class.java)) {
285
+ editable.removeSpan(span)
286
+ }
287
+ if (type != "paragraph") {
288
+ editable.setSpan(SigxBlockSpan(type, level), pStart, pEnd, Spannable.SPAN_PARAGRAPH)
289
+ }
290
+ isProgrammaticEdit = false
291
+ userHasEdited = true
292
+ localVersion += 1
293
+ mView.invalidate()
294
+ mView.requestLayout()
295
+ reportHeightIfChanged()
296
+ fireChange(isComposing = false)
297
+ fireSelection(caretStart, caretEnd)
298
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
299
+ }
300
+ }
301
+
302
+ @LynxUIMethod
303
+ fun insertText(params: ReadableMap?, callback: Callback?) {
304
+ val text = params?.getString("text") ?: ""
305
+ if (text.isEmpty()) {
306
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
307
+ return
308
+ }
309
+ mainHandler.post {
310
+ val editable = mView.text
311
+ if (isComposing(editable)) {
312
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("applied" to false, "reason" to "composing"))
313
+ return@post
314
+ }
315
+ val start = minOf(mView.selectionStart, mView.selectionEnd).coerceAtLeast(0)
316
+ val end = maxOf(mView.selectionStart, mView.selectionEnd).coerceAtLeast(0)
317
+ // Goes through the TextWatcher (not flagged programmatic) so typing
318
+ // overrides apply and change/height fire exactly like typed input.
319
+ editable.replace(start, end, text)
320
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("applied" to true))
321
+ }
322
+ }
323
+
324
+ @LynxUIMethod
325
+ fun setSelectionRange(params: ReadableMap?, callback: Callback?) {
326
+ val start = params?.getInt("start") ?: 0
327
+ val end = if (params?.hasKey("end") == true) params.getInt("end") else start
328
+ mainHandler.post {
329
+ val upper = mView.text.length
330
+ mView.setSelection(start.coerceIn(0, upper), end.coerceIn(0, upper))
331
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
332
+ }
333
+ }
334
+
335
+ @LynxUIMethod
336
+ fun focus(params: ReadableMap?, callback: Callback?) {
337
+ mainHandler.post {
338
+ focusAndShowIme()
339
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
340
+ }
341
+ }
342
+
343
+ @LynxUIMethod
344
+ fun blur(params: ReadableMap?, callback: Callback?) {
345
+ mainHandler.post {
346
+ mView.clearFocus()
347
+ val imm = mView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
348
+ imm?.hideSoftInputFromWindow(mView.windowToken, 0)
349
+ callback?.invoke(LynxUIMethodConstants.SUCCESS)
350
+ }
351
+ }
352
+
353
+ // ── Events ───────────────────────────────────────────────────────────
354
+
355
+ private fun fireEvent(name: String, params: Map<String, Any?>) {
356
+ val event = LynxDetailEvent(sign, name)
357
+ for ((k, v) in params) event.addDetail(k, v)
358
+ lynxContext.eventEmitter.sendCustomEvent(event)
359
+ }
360
+
361
+ private fun fireChange(isComposing: Boolean) {
362
+ val json = DocumentMapper.encode(mView.text, localVersion)
363
+ fireEvent("change", mapOf("doc" to json, "isComposing" to isComposing))
364
+ }
365
+
366
+ private fun fireSelection(start: Int, end: Int) {
367
+ val editable = mView.text
368
+ val formats = mutableListOf<String>()
369
+ for (type in listOf("bold", "italic", "strike", "code")) {
370
+ val active = if (start == end) {
371
+ typingOverrides[type] ?: formatActiveAt(editable, start, type)
372
+ } else {
373
+ rangeFullyHas(editable, type, start, end)
374
+ }
375
+ if (active) formats.add(type)
376
+ }
377
+ if (editable.getSpans(start, maxOf(start, end), SigxLinkSpan::class.java).isNotEmpty()) {
378
+ formats.add("link")
379
+ }
380
+
381
+ var activeBlock = "paragraph"
382
+ var headingLevel: Int? = null
383
+ val blockSpans = editable.getSpans(start, maxOf(start, end), SigxBlockSpan::class.java)
384
+ if (blockSpans.isNotEmpty()) {
385
+ activeBlock = blockSpans[0].type
386
+ if (blockSpans[0].level > 0) headingLevel = blockSpans[0].level
387
+ }
388
+
389
+ val density = mView.resources.displayMetrics.density
390
+ var caretX = 0f
391
+ var caretY = 0f
392
+ var caretH = 0f
393
+ mView.layout?.let { layout ->
394
+ val pos = end.coerceIn(0, editable.length)
395
+ val line = layout.getLineForOffset(pos)
396
+ caretX = (layout.getPrimaryHorizontal(pos) + mView.paddingLeft) / density
397
+ caretY = (layout.getLineTop(line) + mView.paddingTop - mView.scrollY) / density
398
+ caretH = (layout.getLineBottom(line) - layout.getLineTop(line)) / density
399
+ }
400
+
401
+ val params = mutableMapOf<String, Any?>(
402
+ "start" to start,
403
+ "end" to end,
404
+ "activeFormats" to formats.joinToString(","),
405
+ "activeBlock" to activeBlock,
406
+ "caretX" to caretX,
407
+ "caretY" to caretY,
408
+ "caretHeight" to caretH,
409
+ )
410
+ if (headingLevel != null) params["headingLevel"] = headingLevel
411
+ fireEvent("selection", params)
412
+ }
413
+
414
+ private fun reportHeightIfChanged() {
415
+ val density = mView.resources.displayMetrics.density
416
+ val content = mView.contentHeight() / density
417
+ val clamped = maxOf(minHeightPx, if (maxHeightPx > 0f) minOf(content, maxHeightPx) else content)
418
+ if (kotlin.math.abs(clamped - lastReportedHeight) >= 0.5f) {
419
+ lastReportedHeight = clamped
420
+ val lines = mView.lineCount.coerceAtLeast(1)
421
+ fireEvent("heightchange", mapOf("height" to clamped, "lines" to lines))
422
+ }
423
+ }
424
+
425
+ // ── Helpers ──────────────────────────────────────────────────────────
426
+
427
+ private fun focusAndShowIme() {
428
+ mView.requestFocus()
429
+ val imm = mView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
430
+ imm?.showSoftInput(mView, InputMethodManager.SHOW_IMPLICIT)
431
+ }
432
+
433
+ private fun isComposing(editable: Editable): Boolean =
434
+ BaseInputConnection.getComposingSpanStart(editable) >= 0
435
+
436
+ private fun applyTypingOverrides(editable: Editable) {
437
+ if (typingOverrides.isEmpty() || pendingInsertStart < 0 || pendingInsertEnd <= pendingInsertStart) return
438
+ val start = pendingInsertStart.coerceIn(0, editable.length)
439
+ val end = pendingInsertEnd.coerceIn(start, editable.length)
440
+ isProgrammaticEdit = true
441
+ for ((type, on) in typingOverrides) {
442
+ if (on) {
443
+ if (!rangeFullyHas(editable, type, start, end)) {
444
+ editable.setSpan(newMarker(type), start, end, DocumentMapper.INLINE_FLAGS)
445
+ }
446
+ } else {
447
+ removeFormatRange(editable, type, start, end)
448
+ }
449
+ }
450
+ isProgrammaticEdit = false
451
+ }
452
+
453
+ private fun markerClass(type: String): Class<*>? = when (type) {
454
+ "bold" -> SigxBoldSpan::class.java
455
+ "italic" -> SigxItalicSpan::class.java
456
+ "strike" -> SigxStrikeSpan::class.java
457
+ "code" -> SigxCodeSpan::class.java
458
+ else -> null
459
+ }
460
+
461
+ private fun newMarker(type: String): Any = when (type) {
462
+ "bold" -> SigxBoldSpan()
463
+ "italic" -> SigxItalicSpan()
464
+ "strike" -> SigxStrikeSpan()
465
+ "code" -> SigxCodeSpan(theme.codeBackground)
466
+ else -> throw IllegalArgumentException("unknown inline format: $type")
467
+ }
468
+
469
+ private fun formatActiveAt(editable: Editable, offset: Int, type: String): Boolean {
470
+ val cls = markerClass(type) ?: return false
471
+ // The char *before* the caret carries typing inheritance (iOS parity).
472
+ val probe = (offset - 1).coerceAtLeast(0)
473
+ return editable.getSpans(probe, offset, cls).any {
474
+ editable.getSpanStart(it) < offset && editable.getSpanEnd(it) >= offset
475
+ }
476
+ }
477
+
478
+ private fun rangeFullyHas(editable: Editable, type: String, start: Int, end: Int): Boolean {
479
+ val cls = markerClass(type) ?: return false
480
+ if (end <= start) return false
481
+ var covered = 0
482
+ val ranges = editable.getSpans(start, end, cls)
483
+ .map { maxOf(editable.getSpanStart(it), start) to minOf(editable.getSpanEnd(it), end) }
484
+ .filter { it.second > it.first }
485
+ .sortedBy { it.first }
486
+ var cursor = start
487
+ for ((s, e) in ranges) {
488
+ if (s > cursor) return false
489
+ covered += e - maxOf(s, cursor)
490
+ cursor = maxOf(cursor, e)
491
+ }
492
+ return cursor >= end && covered > 0
493
+ }
494
+
495
+ /** Remove a format from [start, end), splitting spans that extend past it. */
496
+ private fun removeFormatRange(editable: Editable, type: String, start: Int, end: Int) {
497
+ val cls = markerClass(type) ?: return
498
+ for (span in editable.getSpans(start, end, cls)) {
499
+ val s = editable.getSpanStart(span)
500
+ val e = editable.getSpanEnd(span)
501
+ editable.removeSpan(span)
502
+ if (s < start) editable.setSpan(newMarker(type), s, start, DocumentMapper.INLINE_FLAGS)
503
+ if (e > end) editable.setSpan(newMarker(type), end, e, DocumentMapper.INLINE_FLAGS)
504
+ }
505
+ }
506
+
507
+ private fun resultMap(vararg pairs: Pair<String, Any?>): JavaOnlyMap {
508
+ val map = JavaOnlyMap()
509
+ for ((k, v) in pairs) {
510
+ when (v) {
511
+ is Boolean -> map.putBoolean(k, v)
512
+ is Int -> map.putInt(k, v)
513
+ is String -> map.putString(k, v)
514
+ is Double -> map.putDouble(k, v)
515
+ null -> map.putNull(k)
516
+ else -> map.putString(k, v.toString())
517
+ }
518
+ }
519
+ return map
520
+ }
521
+
522
+ private fun parseColor(value: String?): Int? {
523
+ val raw = value?.trim()?.removePrefix("#") ?: return null
524
+ if (raw.isEmpty()) return null
525
+ return try {
526
+ when (raw.length) {
527
+ 3 -> android.graphics.Color.parseColor("#" + raw.map { "$it$it" }.joinToString(""))
528
+ 6 -> android.graphics.Color.parseColor("#$raw")
529
+ // #RRGGBBAA → Android wants #AARRGGBB.
530
+ 8 -> android.graphics.Color.parseColor("#" + raw.substring(6, 8) + raw.substring(0, 6))
531
+ else -> null
532
+ }
533
+ } catch (_: IllegalArgumentException) {
534
+ null
535
+ }
536
+ }
537
+ }
@@ -0,0 +1,87 @@
1
+ package com.sigx.richtext
2
+
3
+ import android.graphics.Typeface
4
+ import android.text.TextPaint
5
+ import android.text.style.CharacterStyle
6
+ import android.text.style.MetricAffectingSpan
7
+ import android.text.style.StrikethroughSpan
8
+ import android.text.style.StyleSpan
9
+
10
+ /**
11
+ * Marker spans — the *model* lives in the span classes themselves.
12
+ *
13
+ * Each sigx span both marks a model fact (bold/italic/…) and draws its visual,
14
+ * so readback only has to enumerate these classes: a heading's bold paint
15
+ * never reads back as a `bold` span because only [SigxBoldSpan] produces one.
16
+ * (Mirrors the custom-attribute scheme on iOS.)
17
+ *
18
+ * Spans move with edits exactly like any Android span; inline spans use
19
+ * `SPAN_EXCLUSIVE_INCLUSIVE` so typing at a format's trailing edge extends it
20
+ * (and at the leading edge does not) — matching iOS `typingAttributes`
21
+ * inheritance.
22
+ */
23
+
24
+ class SigxBoldSpan : StyleSpan(Typeface.BOLD)
25
+
26
+ class SigxItalicSpan : StyleSpan(Typeface.ITALIC)
27
+
28
+ class SigxStrikeSpan : StrikethroughSpan()
29
+
30
+ /** Inline code: monospace, slightly smaller, subtle background. */
31
+ class SigxCodeSpan(private val backgroundColor: Int) : MetricAffectingSpan() {
32
+ override fun updateMeasureState(paint: TextPaint) = apply(paint)
33
+ override fun updateDrawState(paint: TextPaint) {
34
+ apply(paint)
35
+ paint.bgColor = backgroundColor
36
+ }
37
+
38
+ private fun apply(paint: TextPaint) {
39
+ paint.typeface = Typeface.MONOSPACE
40
+ paint.textSize = paint.textSize * 0.95f
41
+ }
42
+ }
43
+
44
+ /** Link: accent color + underline. Carries the href (the model payload). */
45
+ class SigxLinkSpan(val href: String, private val color: Int) : CharacterStyle() {
46
+ override fun updateDrawState(paint: TextPaint) {
47
+ paint.color = color
48
+ paint.isUnderlineText = true
49
+ }
50
+ }
51
+
52
+ /** Mention chip placeholder (P3 — atomic rendering lands with ReplacementSpan). */
53
+ class SigxMentionSpan(val attrs: Map<String, String>, private val color: Int) : CharacterStyle() {
54
+ override fun updateDrawState(paint: TextPaint) {
55
+ paint.color = color
56
+ paint.isUnderlineText = true
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Paragraph-level block style. Applied with `SPAN_PARAGRAPH` over line ranges;
62
+ * headings scale + embolden the paint.
63
+ */
64
+ class SigxBlockSpan(
65
+ val type: String,
66
+ val level: Int = 0,
67
+ val checked: Boolean = false,
68
+ ) : MetricAffectingSpan() {
69
+
70
+ override fun updateMeasureState(paint: TextPaint) = apply(paint)
71
+ override fun updateDrawState(paint: TextPaint) = apply(paint)
72
+
73
+ private fun apply(paint: TextPaint) {
74
+ if (type == "heading") {
75
+ val (scale, bold) = when (level) {
76
+ 1 -> 1.75f to true
77
+ 2 -> 1.5f to true
78
+ 3 -> 1.25f to true
79
+ else -> 1.1f to true
80
+ }
81
+ paint.textSize = paint.textSize * scale
82
+ if (bold) paint.typeface = Typeface.create(paint.typeface, Typeface.BOLD)
83
+ } else if (type == "codeBlock") {
84
+ paint.typeface = Typeface.MONOSPACE
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `<RichTextInput>` — typed SignalX wrapper over the native `<sigx-richtext>`
3
+ * element.
4
+ *
5
+ * This is the *generic* attributed-text input: it knows nothing about
6
+ * markdown. `@sigx/lynx-markdown`'s `MarkdownEditor` builds the markdown
7
+ * mapping, toolbar, and plugin system on top of it.
8
+ *
9
+ * Events are decoded into typed shapes (`RichDoc`, `SelectionState`) before
10
+ * reaching handlers; commands go through {@link RichTextMethods} with the
11
+ * element handle delivered via `onElement`.
12
+ */
13
+ import { type Define } from '@sigx/lynx';
14
+ import './jsx-augment.js';
15
+ import type { RichDoc, SelectionState } from './model/types.js';
16
+ import type { RichTextHandle } from './methods.js';
17
+ export type RichTextInputProps = Define.Prop<'value', RichDoc | string, false> & Define.Prop<'placeholder', string, false> & Define.Prop<'editable', boolean, false> & Define.Prop<'minHeight', number, false> & Define.Prop<'maxHeight', number, false> & Define.Prop<'fontSize', number, false> & Define.Prop<'textColor', string, false> & Define.Prop<'accentColor', string, false> & Define.Prop<'placeholderColor', string, false> & Define.Prop<'confirmType', 'send' | 'search' | 'next' | 'go' | 'done', false> & Define.Prop<'autoFocus', boolean, false> & Define.Prop<'class', string, false> & Define.Prop<'style', string | Record<string, string | number>, false>
18
+ /** Receives the BG element handle for {@link RichTextMethods} commands. */
19
+ & Define.Prop<'onElement', (el: RichTextHandle) => void, false> & Define.Prop<'onChange', (doc: RichDoc, isComposing: boolean) => void, false> & Define.Prop<'onSelection', (sel: SelectionState) => void, false> & Define.Prop<'onHeightChange', (height: number, lines: number) => void, false> & Define.Prop<'onFocus', () => void, false> & Define.Prop<'onBlur', () => void, false>;
20
+ export declare const RichTextInput: import("@sigx/runtime-core").ComponentFactory<RichTextInputProps, void, {}>;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
2
+ /**
3
+ * `<RichTextInput>` — typed SignalX wrapper over the native `<sigx-richtext>`
4
+ * element.
5
+ *
6
+ * This is the *generic* attributed-text input: it knows nothing about
7
+ * markdown. `@sigx/lynx-markdown`'s `MarkdownEditor` builds the markdown
8
+ * mapping, toolbar, and plugin system on top of it.
9
+ *
10
+ * Events are decoded into typed shapes (`RichDoc`, `SelectionState`) before
11
+ * reaching handlers; commands go through {@link RichTextMethods} with the
12
+ * element handle delivered via `onElement`.
13
+ */
14
+ import { component } from '@sigx/lynx';
15
+ import './jsx-augment.js';
16
+ import { decodeDoc } from './model/codec.js';
17
+ export const RichTextInput = component(({ props }) => {
18
+ const handleChange = (e) => {
19
+ props.onChange?.(decodeDoc(e.detail.doc), !!e.detail.isComposing);
20
+ };
21
+ const handleSelection = (e) => {
22
+ const d = e.detail;
23
+ props.onSelection?.({
24
+ start: d.start,
25
+ end: d.end,
26
+ activeFormats: parseFormats(d.activeFormats),
27
+ activeBlock: (d.activeBlock || 'paragraph'),
28
+ ...(d.headingLevel !== undefined ? { headingLevel: d.headingLevel } : {}),
29
+ caretRect: { x: d.caretX ?? 0, y: d.caretY ?? 0, height: d.caretHeight ?? 0 },
30
+ });
31
+ };
32
+ const handleHeight = (e) => {
33
+ props.onHeightChange?.(e.detail.height, e.detail.lines);
34
+ };
35
+ return () => (_jsx("sigx-richtext", { ref: (el) => props.onElement?.(el), value: typeof props.value === 'string' ? props.value : props.value ? JSON.stringify(props.value) : undefined, placeholder: props.placeholder, editable: props.editable, "min-height": props.minHeight, "max-height": props.maxHeight, "font-size": props.fontSize, "text-color": props.textColor, "accent-color": props.accentColor, "placeholder-color": props.placeholderColor, "confirm-type": props.confirmType, "auto-focus": props.autoFocus, class: props.class, style: props.style, bindchange: handleChange, bindselection: handleSelection, bindheightchange: handleHeight, bindfocus: () => props.onFocus?.(), bindblur: () => props.onBlur?.() }));
36
+ });
37
+ function parseFormats(raw) {
38
+ if (!raw)
39
+ return [];
40
+ return raw.split(',').filter(Boolean);
41
+ }