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