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