@sigx/lynx-richtext 0.4.7 → 0.4.9
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.
|
@@ -31,7 +31,14 @@ import com.lynx.tasm.event.LynxDetailEvent
|
|
|
31
31
|
*/
|
|
32
32
|
class SigxRichTextUI(context: LynxContext) : LynxUI<RichEditText>(context) {
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Lazy: LynxUI's super constructor calls createView() BEFORE this
|
|
35
|
+
// class's property initializers run, so `theme` must not be touched
|
|
36
|
+
// there. First access happens post-construction, when mView exists —
|
|
37
|
+
// seeding from the view's platform-default color so derived visuals
|
|
38
|
+
// match until the `text-color` prop overrides it.
|
|
39
|
+
private val theme by lazy {
|
|
40
|
+
RichTextTheme().apply { textColor = mView.currentTextColor }
|
|
41
|
+
}
|
|
35
42
|
private var localVersion = 0
|
|
36
43
|
private var userHasEdited = false
|
|
37
44
|
private var minHeightPx = 0f
|
|
@@ -53,9 +60,6 @@ class SigxRichTextUI(context: LynxContext) : LynxUI<RichEditText>(context) {
|
|
|
53
60
|
|
|
54
61
|
override fun createView(context: Context): RichEditText {
|
|
55
62
|
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
63
|
view.inputType = InputType.TYPE_CLASS_TEXT or
|
|
60
64
|
InputType.TYPE_TEXT_FLAG_MULTI_LINE or
|
|
61
65
|
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
|
@@ -116,7 +120,11 @@ class SigxRichTextUI(context: LynxContext) : LynxUI<RichEditText>(context) {
|
|
|
116
120
|
mView.hint = value ?: ""
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
|
|
123
|
+
/** Editable unless explicitly disabled — `defaultBoolean = true` makes a
|
|
124
|
+
* null/absent value mean editable, mirroring iOS (`value?.boolValue ?? true`).
|
|
125
|
+
* Without it, `editable={undefined}` from JS coerces to false and the
|
|
126
|
+
* EditText becomes permanently unfocusable (#182). */
|
|
127
|
+
@LynxProp(name = "editable", defaultBoolean = true)
|
|
120
128
|
fun setEditable(value: Boolean) {
|
|
121
129
|
mView.isEnabled = value
|
|
122
130
|
mView.isFocusable = value
|
|
@@ -249,16 +257,44 @@ class SigxRichTextUI(context: LynxContext) : LynxUI<RichEditText>(context) {
|
|
|
249
257
|
// Collapsed: tri-state typing override (Android's typingAttributes).
|
|
250
258
|
val inherited = formatActiveAt(editable, start, type)
|
|
251
259
|
val current = typingOverrides[type] ?: inherited
|
|
260
|
+
if (!current) {
|
|
261
|
+
// `code` is terminal (mirrors the markdown serializer):
|
|
262
|
+
// turning it on clears the marks it excludes, and the
|
|
263
|
+
// excluded marks can't turn on inside it.
|
|
264
|
+
val codeActive = typingOverrides["code"] ?: formatActiveAt(editable, start, "code")
|
|
265
|
+
if (type == "code") {
|
|
266
|
+
typingOverrides["bold"] = false
|
|
267
|
+
typingOverrides["italic"] = false
|
|
268
|
+
typingOverrides["strike"] = false
|
|
269
|
+
} else if (codeActive) {
|
|
270
|
+
fireSelection(start, end)
|
|
271
|
+
callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("active" to false))
|
|
272
|
+
return@post
|
|
273
|
+
}
|
|
274
|
+
}
|
|
252
275
|
typingOverrides[type] = !current
|
|
253
276
|
fireSelection(start, end)
|
|
254
277
|
callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("active" to !current))
|
|
255
278
|
return@post
|
|
256
279
|
}
|
|
257
280
|
val active = rangeFullyHas(editable, type, start, end)
|
|
281
|
+
// `code` is terminal (mirrors the markdown serializer): the marks
|
|
282
|
+
// it excludes can't turn on across an all-code selection.
|
|
283
|
+
if (!active && type != "code" && rangeFullyHas(editable, "code", start, end)) {
|
|
284
|
+
fireSelection(start, end)
|
|
285
|
+
callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("active" to false))
|
|
286
|
+
return@post
|
|
287
|
+
}
|
|
258
288
|
isProgrammaticEdit = true
|
|
259
289
|
if (active) {
|
|
260
290
|
removeFormatRange(editable, type, start, end)
|
|
261
291
|
} else {
|
|
292
|
+
if (type == "code") {
|
|
293
|
+
// Turning code on strips the marks it excludes.
|
|
294
|
+
removeFormatRange(editable, "bold", start, end)
|
|
295
|
+
removeFormatRange(editable, "italic", start, end)
|
|
296
|
+
removeFormatRange(editable, "strike", start, end)
|
|
297
|
+
}
|
|
262
298
|
editable.setSpan(newMarker(type), start, end, DocumentMapper.INLINE_FLAGS)
|
|
263
299
|
}
|
|
264
300
|
isProgrammaticEdit = false
|
package/dist/model/codec.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* `decodeDoc` is defensive: malformed input degrades to an empty document
|
|
7
7
|
* rather than throwing (events from native should never crash the BG thread).
|
|
8
8
|
*/
|
|
9
|
+
const INLINE_TYPES = new Set(['bold', 'italic', 'strike', 'code', 'link', 'mention']);
|
|
10
|
+
const BLOCK_TYPES = new Set(['paragraph', 'heading', 'bullet', 'ordered', 'task', 'blockquote', 'codeBlock', 'raw']);
|
|
9
11
|
export function emptyDoc(v = 0) {
|
|
10
12
|
return { text: '', spans: [], blocks: [], v };
|
|
11
13
|
}
|
|
@@ -34,13 +36,16 @@ function sanitizeSpans(spans, max) {
|
|
|
34
36
|
return [];
|
|
35
37
|
const out = [];
|
|
36
38
|
for (const s of spans) {
|
|
37
|
-
if (!s || typeof s.start !== 'number' || typeof s.end !== 'number'
|
|
39
|
+
if (!s || typeof s.start !== 'number' || typeof s.end !== 'number')
|
|
40
|
+
continue;
|
|
41
|
+
if (typeof s.type !== 'string' || !INLINE_TYPES.has(s.type))
|
|
38
42
|
continue;
|
|
39
43
|
const start = clamp(s.start, 0, max);
|
|
40
44
|
const end = clamp(s.end, 0, max);
|
|
41
45
|
if (end <= start)
|
|
42
46
|
continue;
|
|
43
|
-
|
|
47
|
+
const attrs = s.attrs && typeof s.attrs === 'object' && !Array.isArray(s.attrs) ? s.attrs : undefined;
|
|
48
|
+
out.push({ start, end, type: s.type, ...(attrs ? { attrs } : {}) });
|
|
44
49
|
}
|
|
45
50
|
return out;
|
|
46
51
|
}
|
|
@@ -49,7 +54,9 @@ function sanitizeBlocks(blocks, max) {
|
|
|
49
54
|
return [];
|
|
50
55
|
const out = [];
|
|
51
56
|
for (const b of blocks) {
|
|
52
|
-
if (!b || typeof b.start !== 'number' || typeof b.end !== 'number'
|
|
57
|
+
if (!b || typeof b.start !== 'number' || typeof b.end !== 'number')
|
|
58
|
+
continue;
|
|
59
|
+
if (typeof b.type !== 'string' || !BLOCK_TYPES.has(b.type))
|
|
53
60
|
continue;
|
|
54
61
|
const start = clamp(b.start, 0, max);
|
|
55
62
|
const end = clamp(b.end, 0, max);
|
|
@@ -59,8 +66,8 @@ function sanitizeBlocks(blocks, max) {
|
|
|
59
66
|
start,
|
|
60
67
|
end,
|
|
61
68
|
type: b.type,
|
|
62
|
-
...(b.level
|
|
63
|
-
...(b.checked
|
|
69
|
+
...(typeof b.level === 'number' && Number.isFinite(b.level) ? { level: Math.floor(b.level) } : {}),
|
|
70
|
+
...(typeof b.checked === 'boolean' ? { checked: b.checked } : {}),
|
|
64
71
|
});
|
|
65
72
|
}
|
|
66
73
|
return out;
|
package/ios/DocumentMapper.swift
CHANGED
|
@@ -142,20 +142,25 @@ enum DocumentMapper {
|
|
|
142
142
|
font = theme.headingFont(level: block["level"] as? Int ?? 1)
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
// `code` is terminal (mirrors the markdown serializer, where
|
|
146
|
+
// computeRuns drops every mark but `link` inside a code span):
|
|
147
|
+
// bold/italic/strike never render inside a code run, so the
|
|
148
|
+
// field can't show styling that serialization would discard.
|
|
149
|
+
let isCode = attrs[SigxAttr.code] != nil
|
|
150
|
+
if isCode {
|
|
146
151
|
font = theme.codeFont
|
|
147
152
|
background = theme.codeBackground
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
var traits = font.fontDescriptor.symbolicTraits
|
|
151
|
-
if attrs[SigxAttr.bold] != nil { traits.insert(.traitBold) }
|
|
152
|
-
if attrs[SigxAttr.italic] != nil { traits.insert(.traitItalic) }
|
|
156
|
+
if !isCode, attrs[SigxAttr.bold] != nil { traits.insert(.traitBold) }
|
|
157
|
+
if !isCode, attrs[SigxAttr.italic] != nil { traits.insert(.traitItalic) }
|
|
153
158
|
if traits != font.fontDescriptor.symbolicTraits,
|
|
154
159
|
let descriptor = font.fontDescriptor.withSymbolicTraits(traits) {
|
|
155
160
|
font = UIFont(descriptor: descriptor, size: font.pointSize)
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
if attrs[SigxAttr.strike] != nil { strike = true }
|
|
163
|
+
if !isCode, attrs[SigxAttr.strike] != nil { strike = true }
|
|
159
164
|
if attrs[SigxAttr.link] != nil {
|
|
160
165
|
color = theme.accentColor
|
|
161
166
|
underline = true
|
package/ios/SigxRichTextUI.swift
CHANGED
|
@@ -263,7 +263,23 @@ public class SigxRichTextUI: LynxUI<RichTextView> {
|
|
|
263
263
|
// carries (or drops) the format.
|
|
264
264
|
var typing = view.typingAttributes
|
|
265
265
|
let active = typing[key] != nil
|
|
266
|
-
if active {
|
|
266
|
+
if active {
|
|
267
|
+
typing.removeValue(forKey: key)
|
|
268
|
+
} else {
|
|
269
|
+
// `code` is terminal (mirrors the markdown serializer):
|
|
270
|
+
// turning it on clears the marks it excludes, and the
|
|
271
|
+
// excluded marks can't turn on inside it.
|
|
272
|
+
if key == SigxAttr.code {
|
|
273
|
+
typing.removeValue(forKey: SigxAttr.bold)
|
|
274
|
+
typing.removeValue(forKey: SigxAttr.italic)
|
|
275
|
+
typing.removeValue(forKey: SigxAttr.strike)
|
|
276
|
+
} else if typing[SigxAttr.code] != nil, key != SigxAttr.link {
|
|
277
|
+
self.fireSelection()
|
|
278
|
+
callback(SigxRichTextUI.kUIMethodSuccess, ["active": false])
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
typing[key] = true
|
|
282
|
+
}
|
|
267
283
|
typing[.font] = SigxRichTextUI.deriveTypingFont(from: typing, theme: self.theme)
|
|
268
284
|
view.typingAttributes = typing
|
|
269
285
|
self.fireSelection()
|
|
@@ -275,11 +291,25 @@ public class SigxRichTextUI: LynxUI<RichTextView> {
|
|
|
275
291
|
return
|
|
276
292
|
}
|
|
277
293
|
let active = SigxRichTextUI.rangeFullyHasAttribute(storage, key: key, range: selection)
|
|
294
|
+
// `code` is terminal (mirrors the markdown serializer): the marks
|
|
295
|
+
// it excludes can't turn on across an all-code selection.
|
|
296
|
+
if !active, key != SigxAttr.code, key != SigxAttr.link,
|
|
297
|
+
SigxRichTextUI.rangeFullyHasAttribute(storage, key: SigxAttr.code, range: selection) {
|
|
298
|
+
self.fireSelection()
|
|
299
|
+
callback(SigxRichTextUI.kUIMethodSuccess, ["active": false])
|
|
300
|
+
return
|
|
301
|
+
}
|
|
278
302
|
self.isProgrammaticEdit = true
|
|
279
303
|
storage.beginEditing()
|
|
280
304
|
if active {
|
|
281
305
|
storage.removeAttribute(key, range: selection)
|
|
282
306
|
} else {
|
|
307
|
+
if key == SigxAttr.code {
|
|
308
|
+
// Turning code on strips the marks it excludes.
|
|
309
|
+
storage.removeAttribute(SigxAttr.bold, range: selection)
|
|
310
|
+
storage.removeAttribute(SigxAttr.italic, range: selection)
|
|
311
|
+
storage.removeAttribute(SigxAttr.strike, range: selection)
|
|
312
|
+
}
|
|
283
313
|
storage.addAttribute(key, value: true, range: selection)
|
|
284
314
|
}
|
|
285
315
|
DocumentMapper.refreshVisuals(storage, range: selection, theme: self.theme)
|
|
@@ -503,7 +533,9 @@ public class SigxRichTextUI: LynxUI<RichTextView> {
|
|
|
503
533
|
(block["type"] as? String) == "heading" {
|
|
504
534
|
font = theme.headingFont(level: block["level"] as? Int ?? 1)
|
|
505
535
|
}
|
|
506
|
-
|
|
536
|
+
// `code` is terminal (matches the serializer + applyBaseVisuals):
|
|
537
|
+
// characters typed inside a code run never pick up bold/italic.
|
|
538
|
+
if attrs[SigxAttr.code] != nil { return theme.codeFont }
|
|
507
539
|
var traits = font.fontDescriptor.symbolicTraits
|
|
508
540
|
if attrs[SigxAttr.bold] != nil { traits.insert(.traitBold) }
|
|
509
541
|
if attrs[SigxAttr.italic] != nil { traits.insert(.traitItalic) }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigx/lynx-richtext",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Native rich-text input element for Lynx (<sigx-richtext>): attributed editing (UITextView / EditText) with a span-based document model, selection events, and formatting commands. Powers @sigx/lynx-markdown's MarkdownEditor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -23,13 +23,13 @@
|
|
|
23
23
|
"LICENSE"
|
|
24
24
|
],
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"@sigx/lynx": "^0.4.
|
|
26
|
+
"@sigx/lynx": "^0.4.9"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@typescript/native-preview": "7.0.0-dev.20260521.1",
|
|
30
30
|
"typescript": "^6.0.3",
|
|
31
|
-
"@sigx/lynx
|
|
32
|
-
"@sigx/lynx": "^0.4.
|
|
31
|
+
"@sigx/lynx": "^0.4.9",
|
|
32
|
+
"@sigx/lynx-testing": "^0.4.9"
|
|
33
33
|
},
|
|
34
34
|
"author": "Andreas Ekdahl",
|
|
35
35
|
"license": "MIT",
|