@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Andreas Ekdahl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @sigx/lynx-richtext
2
+
3
+ A **native rich-text input element** for Lynx: `<sigx-richtext>` wraps
4
+ `UITextView` (iOS) and `EditText` (Android) with attributed-text editing —
5
+ bold is bold *inside* the editable field. It powers `@sigx/lynx-markdown`'s
6
+ `MarkdownEditor`, but is markdown-agnostic and usable on its own.
7
+
8
+ ## The document model
9
+
10
+ A flat, span-based `RichDoc` crosses the bridge — it maps 1:1 onto
11
+ `NSAttributedString` / `Spannable` (UTF-16 offsets everywhere, zero index
12
+ translation):
13
+
14
+ ```ts
15
+ interface RichDoc {
16
+ text: string; // flat text, '\n' separates paragraphs
17
+ spans: InlineSpan[]; // {start, end, type: 'bold'|'italic'|'strike'|'code'|'link'|'mention', attrs?}
18
+ blocks: BlockAttr[]; // paragraph ranges: {start, end, type: 'paragraph'|'heading'|…, level?}
19
+ v: number; // monotonic version (echo/stale-write protection)
20
+ }
21
+ ```
22
+
23
+ Native never parses markdown; after every edit it **reads the model back out of
24
+ its own text storage** (explicit marker attributes/spans, so a heading's bold
25
+ font never reads back as a `bold` span) and emits the full doc.
26
+
27
+ ## Use
28
+
29
+ ```tsx
30
+ import { RichTextInput, RichTextMethods, type RichTextHandle } from '@sigx/lynx-richtext';
31
+
32
+ let el: RichTextHandle = null;
33
+
34
+ <RichTextInput
35
+ placeholder="Write something…"
36
+ minHeight={40}
37
+ maxHeight={160}
38
+ onElement={(handle) => { el = handle; }}
39
+ onChange={(doc, isComposing) => { /* doc is a decoded RichDoc */ }}
40
+ onSelection={(sel) => { /* sel.activeFormats drives toolbar state */ }}
41
+ onHeightChange={(h) => { /* auto-grow: set the element's style height */ }}
42
+ />;
43
+
44
+ // Commands are fire-and-forget (BG → MT via the INVOKE_UI_METHOD op);
45
+ // state reconciles through the next bindchange/bindselection.
46
+ RichTextMethods.toggleFormat(el, 'bold');
47
+ RichTextMethods.setBlockType(el, 'heading', 2);
48
+ RichTextMethods.insertText(el, '🎉');
49
+ ```
50
+
51
+ ## IME / echo contract
52
+
53
+ 1. Every user edit bumps the doc version; `bindchange` carries it.
54
+ 2. `setDocument` with structurally-identical content is a silent no-op.
55
+ 3. `setDocument` based on a stale version is dropped; current state re-emits.
56
+ 4. `setDocument` during an active IME composition is dropped (composition
57
+ would be corrupted); `bindchange` flags `isComposing` so callers never echo
58
+ mid-composition.
59
+
60
+ Prefer the **lightly-controlled** pattern: don't echo keystrokes back — treat
61
+ the element as the source of truth for live text and push `setDocument` only
62
+ for genuine programmatic mutations (load, clear-on-send, block changes).
63
+
64
+ ## Beyond markdown
65
+
66
+ The element has **no serialization format of its own** — `RichDoc` is plain
67
+ `text + spans + blocks`, and what those *mean* is the consumer's choice.
68
+ `@sigx/lynx-markdown`'s `MarkdownEditor` is one consumer (its `mdToDoc` /
69
+ `docToMd` converters give the doc markdown semantics), but the same element
70
+ backs, for example:
71
+
72
+ - a styled chat/comment input that stores the **doc JSON directly** (no
73
+ text format at all — render it back with the same spans);
74
+ - an editor that serializes to **HTML**, Slack-style mrkdwn, or a custom
75
+ wire format — write the two converters, the rest is identical;
76
+ - a plain input that only uses auto-grow + IME-safe programmatic writes,
77
+ ignoring formatting entirely.
78
+
79
+ The constraint is the **vocabulary**, not the format: v1 ships a fixed set of
80
+ span types (`bold` / `italic` / `strike` / `code` / `link` / `mention`) and
81
+ block types (`paragraph` / `heading` / … / `raw`). Custom mark types arrive
82
+ with the editor plugin API (P3).
83
+
84
+ ## v1 scope
85
+
86
+ Inline: bold / italic / strike / code / link (render + toggle + typing-edge
87
+ inheritance). Blocks: paragraph + headings (1–3 styled; 4–6 fall back to a
88
+ smaller scale). Auto-height reporting (`bindheightchange`) with
89
+ `min-height`/`max-height` clamping and internal scroll past the ceiling.
90
+ Reserved in the model for follow-ups: lists/quote/codeBlock rendering, atomic
91
+ mention chips, task checkboxes.
92
+
93
+ Native code is autolinked by `sigx prebuild` (`signalx-module.json` →
94
+ `ios.uiComponents` / `android.behaviors`); run a native rebuild after adding
95
+ the package.
@@ -0,0 +1,148 @@
1
+ package com.sigx.richtext
2
+
3
+ import android.graphics.Color
4
+ import android.text.Spannable
5
+ import android.text.SpannableStringBuilder
6
+ import android.text.Spanned
7
+ import org.json.JSONArray
8
+ import org.json.JSONObject
9
+
10
+ /** Theme knobs shared by the spans (colors resolved on the UI side). */
11
+ data class RichTextTheme(
12
+ var fontSizePx: Float = 0f, // 0 → leave EditText default
13
+ var textColor: Int = Color.BLACK,
14
+ var accentColor: Int = Color.parseColor("#3478F6"),
15
+ var codeBackground: Int = Color.parseColor("#1F7F7F7F"), // ~12% gray
16
+ )
17
+
18
+ /**
19
+ * RichDoc (JSON) ↔ Spannable mapping. Readback enumerates only the sigx marker
20
+ * spans (see [SigxSpans.kt]) so the model is unambiguous; native storage is
21
+ * authoritative after every edit.
22
+ */
23
+ object DocumentMapper {
24
+
25
+ /** Inline span flag: typing at the trailing edge extends the format. */
26
+ const val INLINE_FLAGS: Int = Spanned.SPAN_EXCLUSIVE_INCLUSIVE
27
+
28
+ data class Parsed(val text: SpannableStringBuilder, val version: Int)
29
+
30
+ /** Parse a JSON RichDoc. Returns null when unparseable. */
31
+ fun parse(json: String, theme: RichTextTheme): Parsed? {
32
+ val obj = try { JSONObject(json) } catch (_: Exception) { return null }
33
+ val text = obj.optString("text", "")
34
+ val builder = SpannableStringBuilder(text)
35
+ val max = builder.length
36
+
37
+ val spans: JSONArray = obj.optJSONArray("spans") ?: JSONArray()
38
+ for (i in 0 until spans.length()) {
39
+ val span = spans.optJSONObject(i) ?: continue
40
+ val start = span.optInt("start", -1).coerceIn(0, max)
41
+ val end = span.optInt("end", -1).coerceIn(0, max)
42
+ if (end <= start) continue
43
+ val attrs = span.optJSONObject("attrs")
44
+ val mark: Any? = when (span.optString("type")) {
45
+ "bold" -> SigxBoldSpan()
46
+ "italic" -> SigxItalicSpan()
47
+ "strike" -> SigxStrikeSpan()
48
+ "code" -> SigxCodeSpan(theme.codeBackground)
49
+ "link" -> SigxLinkSpan(attrs?.optString("href") ?: "", theme.accentColor)
50
+ "mention" -> SigxMentionSpan(attrs.toStringMap(), theme.accentColor)
51
+ else -> null
52
+ }
53
+ if (mark != null) builder.setSpan(mark, start, end, INLINE_FLAGS)
54
+ }
55
+
56
+ val blocks: JSONArray = obj.optJSONArray("blocks") ?: JSONArray()
57
+ for (i in 0 until blocks.length()) {
58
+ val block = blocks.optJSONObject(i) ?: continue
59
+ val type = block.optString("type")
60
+ if (type.isEmpty() || type == "paragraph") continue
61
+ val start = block.optInt("start", -1).coerceIn(0, max)
62
+ val end = block.optInt("end", -1).coerceIn(0, max)
63
+ if (end < start) continue
64
+ val snapped = snapToParagraph(builder, start, end)
65
+ builder.setSpan(
66
+ SigxBlockSpan(type, block.optInt("level", 0), block.optBoolean("checked", false)),
67
+ snapped.first,
68
+ snapped.second,
69
+ Spanned.SPAN_PARAGRAPH,
70
+ )
71
+ }
72
+
73
+ return Parsed(builder, obj.optInt("v", 0))
74
+ }
75
+
76
+ /** Read the model back out of live storage → JSON RichDoc. */
77
+ fun encode(text: Spannable, version: Int): String {
78
+ val doc = JSONObject()
79
+ doc.put("text", text.toString())
80
+
81
+ val spans = JSONArray()
82
+ fun add(start: Int, end: Int, type: String, attrs: JSONObject? = null) {
83
+ if (end <= start) return
84
+ val entry = JSONObject()
85
+ entry.put("start", start)
86
+ entry.put("end", end)
87
+ entry.put("type", type)
88
+ if (attrs != null) entry.put("attrs", attrs)
89
+ spans.put(entry)
90
+ }
91
+
92
+ for (span in text.getSpans(0, text.length, SigxBoldSpan::class.java)) {
93
+ add(text.getSpanStart(span), text.getSpanEnd(span), "bold")
94
+ }
95
+ for (span in text.getSpans(0, text.length, SigxItalicSpan::class.java)) {
96
+ add(text.getSpanStart(span), text.getSpanEnd(span), "italic")
97
+ }
98
+ for (span in text.getSpans(0, text.length, SigxStrikeSpan::class.java)) {
99
+ add(text.getSpanStart(span), text.getSpanEnd(span), "strike")
100
+ }
101
+ for (span in text.getSpans(0, text.length, SigxCodeSpan::class.java)) {
102
+ add(text.getSpanStart(span), text.getSpanEnd(span), "code")
103
+ }
104
+ for (span in text.getSpans(0, text.length, SigxLinkSpan::class.java)) {
105
+ add(
106
+ text.getSpanStart(span), text.getSpanEnd(span), "link",
107
+ JSONObject().put("href", span.href),
108
+ )
109
+ }
110
+ for (span in text.getSpans(0, text.length, SigxMentionSpan::class.java)) {
111
+ val attrs = JSONObject()
112
+ for ((k, v) in span.attrs) attrs.put(k, v)
113
+ add(text.getSpanStart(span), text.getSpanEnd(span), "mention", attrs)
114
+ }
115
+ doc.put("spans", spans)
116
+
117
+ val blocks = JSONArray()
118
+ for (span in text.getSpans(0, text.length, SigxBlockSpan::class.java)) {
119
+ val entry = JSONObject()
120
+ entry.put("start", text.getSpanStart(span))
121
+ entry.put("end", text.getSpanEnd(span))
122
+ entry.put("type", span.type)
123
+ if (span.level > 0) entry.put("level", span.level)
124
+ if (span.type == "task") entry.put("checked", span.checked)
125
+ blocks.put(entry)
126
+ }
127
+ doc.put("blocks", blocks)
128
+ doc.put("v", version)
129
+ return doc.toString()
130
+ }
131
+
132
+ /** Snap [start, end) to enclosing line boundaries (SPAN_PARAGRAPH requirement). */
133
+ fun snapToParagraph(text: CharSequence, start: Int, end: Int): Pair<Int, Int> {
134
+ var s = start.coerceIn(0, text.length)
135
+ var e = end.coerceIn(s, text.length)
136
+ while (s > 0 && text[s - 1] != '\n') s--
137
+ while (e < text.length && text[e] != '\n') e++
138
+ if (e < text.length) e++ // include the trailing newline, paragraph-span style
139
+ return s to e
140
+ }
141
+
142
+ private fun JSONObject?.toStringMap(): Map<String, String> {
143
+ if (this == null) return emptyMap()
144
+ val out = mutableMapOf<String, String>()
145
+ for (key in keys()) out[key] = optString(key)
146
+ return out
147
+ }
148
+ }
@@ -0,0 +1,41 @@
1
+ package com.sigx.richtext
2
+
3
+ import android.content.Context
4
+ import android.view.Gravity
5
+ import android.widget.EditText
6
+
7
+ /**
8
+ * `EditText` subclass backing `<sigx-richtext>`.
9
+ *
10
+ * Adds a selection-change callback (EditText has no listener for it) and a
11
+ * content-height helper for auto-grow reporting. Chip-aware deletion hooks
12
+ * land in P3.
13
+ */
14
+ class RichEditText(context: Context) : EditText(context) {
15
+
16
+ var onSelectionChangedCallback: ((start: Int, end: Int) -> Unit)? = null
17
+
18
+ init {
19
+ background = null
20
+ gravity = Gravity.TOP or Gravity.START
21
+ setPadding(0, dp(8), 0, dp(8))
22
+ // Text color stays at the platform default (textColorPrimary, which
23
+ // tracks the system light/dark theme) unless the `text-color` prop
24
+ // overrides it.
25
+ isVerticalScrollBarEnabled = true
26
+ }
27
+
28
+ override fun onSelectionChanged(selStart: Int, selEnd: Int) {
29
+ super.onSelectionChanged(selStart, selEnd)
30
+ onSelectionChangedCallback?.invoke(selStart, selEnd)
31
+ }
32
+
33
+ /** Intrinsic content height (px) for the current width. */
34
+ fun contentHeight(): Int {
35
+ val l = layout ?: return paddingTop + paddingBottom + lineHeight
36
+ return l.height + paddingTop + paddingBottom
37
+ }
38
+
39
+ private fun dp(value: Int): Int =
40
+ (value * resources.displayMetrics.density).toInt()
41
+ }
@@ -0,0 +1,18 @@
1
+ package com.sigx.richtext
2
+
3
+ import com.lynx.tasm.behavior.Behavior
4
+ import com.lynx.tasm.behavior.LynxContext
5
+ import com.lynx.tasm.behavior.ui.LynxUI
6
+
7
+ /**
8
+ * Registers the `<sigx-richtext>` JSX tag with Lynx's UI registry.
9
+ *
10
+ * Discovered by the autolinker via `signalx-module.json`'s `android.behaviors`;
11
+ * the generated `GeneratedBehaviors.attachAll(builder)` adds this behavior to
12
+ * every `LynxViewBuilder` in the app.
13
+ */
14
+ class SigxRichTextBehavior : Behavior("sigx-richtext") {
15
+ override fun createUI(context: LynxContext): LynxUI<*> {
16
+ return SigxRichTextUI(context)
17
+ }
18
+ }