@sigx/lynx-richtext 0.4.7 → 0.4.8

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
@@ -249,16 +253,44 @@ class SigxRichTextUI(context: LynxContext) : LynxUI<RichEditText>(context) {
249
253
  // Collapsed: tri-state typing override (Android's typingAttributes).
250
254
  val inherited = formatActiveAt(editable, start, type)
251
255
  val current = typingOverrides[type] ?: inherited
256
+ if (!current) {
257
+ // `code` is terminal (mirrors the markdown serializer):
258
+ // turning it on clears the marks it excludes, and the
259
+ // excluded marks can't turn on inside it.
260
+ val codeActive = typingOverrides["code"] ?: formatActiveAt(editable, start, "code")
261
+ if (type == "code") {
262
+ typingOverrides["bold"] = false
263
+ typingOverrides["italic"] = false
264
+ typingOverrides["strike"] = false
265
+ } else if (codeActive) {
266
+ fireSelection(start, end)
267
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("active" to false))
268
+ return@post
269
+ }
270
+ }
252
271
  typingOverrides[type] = !current
253
272
  fireSelection(start, end)
254
273
  callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("active" to !current))
255
274
  return@post
256
275
  }
257
276
  val active = rangeFullyHas(editable, type, start, end)
277
+ // `code` is terminal (mirrors the markdown serializer): the marks
278
+ // it excludes can't turn on across an all-code selection.
279
+ if (!active && type != "code" && rangeFullyHas(editable, "code", start, end)) {
280
+ fireSelection(start, end)
281
+ callback?.invoke(LynxUIMethodConstants.SUCCESS, resultMap("active" to false))
282
+ return@post
283
+ }
258
284
  isProgrammaticEdit = true
259
285
  if (active) {
260
286
  removeFormatRange(editable, type, start, end)
261
287
  } else {
288
+ if (type == "code") {
289
+ // Turning code on strips the marks it excludes.
290
+ removeFormatRange(editable, "bold", start, end)
291
+ removeFormatRange(editable, "italic", start, end)
292
+ removeFormatRange(editable, "strike", start, end)
293
+ }
262
294
  editable.setSpan(newMarker(type), start, end, DocumentMapper.INLINE_FLAGS)
263
295
  }
264
296
  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.8",
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.8"
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.8",
32
+ "@sigx/lynx-testing": "^0.4.8"
33
33
  },
34
34
  "author": "Andreas Ekdahl",
35
35
  "license": "MIT",