@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
- private val theme = RichTextTheme()
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
- @LynxProp(name = "editable")
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
@@ -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' || !s.type)
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
- out.push({ start, end, type: s.type, ...(s.attrs ? { attrs: s.attrs } : {}) });
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' || !b.type)
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 !== undefined ? { level: b.level } : {}),
63
- ...(b.checked !== undefined ? { checked: 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;
@@ -142,20 +142,25 @@ enum DocumentMapper {
142
142
  font = theme.headingFont(level: block["level"] as? Int ?? 1)
143
143
  }
144
144
 
145
- if attrs[SigxAttr.code] != nil {
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
@@ -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 { typing.removeValue(forKey: key) } else { typing[key] = true }
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
- if attrs[SigxAttr.code] != nil { font = theme.codeFont }
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.7",
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.7"
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-testing": "^0.4.7",
32
- "@sigx/lynx": "^0.4.7"
31
+ "@sigx/lynx": "^0.4.9",
32
+ "@sigx/lynx-testing": "^0.4.9"
33
33
  },
34
34
  "author": "Andreas Ekdahl",
35
35
  "license": "MIT",