@apollohg/react-native-prose-editor 0.5.6 → 0.5.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.
@@ -31,7 +31,8 @@ data class NativeMentionsAddonConfig(
31
31
  val trigger: String,
32
32
  val suggestions: List<NativeMentionSuggestion>,
33
33
  val theme: EditorMentionTheme?,
34
- val resolveSelectionAttrs: Boolean
34
+ val resolveSelectionAttrs: Boolean,
35
+ val resolveTheme: Boolean
35
36
  ) {
36
37
  companion object {
37
38
  fun fromJson(json: JSONObject?): NativeMentionsAddonConfig? {
@@ -53,7 +54,8 @@ data class NativeMentionsAddonConfig(
53
54
  trigger = trigger,
54
55
  suggestions = suggestions,
55
56
  theme = EditorMentionTheme.fromJson(json.optJSONObject("theme")),
56
- resolveSelectionAttrs = json.optBoolean("resolveSelectionAttrs", false)
57
+ resolveSelectionAttrs = json.optBoolean("resolveSelectionAttrs", false),
58
+ resolveTheme = json.optBoolean("resolveTheme", false)
57
59
  )
58
60
  }
59
61
  }
@@ -112,6 +112,40 @@ data class EditorBlockquoteTheme(
112
112
  }
113
113
  }
114
114
 
115
+ data class EditorLinkTheme(
116
+ val fontFamily: String? = null,
117
+ val fontSize: Float? = null,
118
+ val fontWeight: String? = null,
119
+ val fontStyle: String? = null,
120
+ val color: Int? = null,
121
+ val backgroundColor: Int? = null,
122
+ val underline: Boolean? = null
123
+ ) {
124
+ companion object {
125
+ fun fromJson(json: JSONObject?): EditorLinkTheme? {
126
+ json ?: return null
127
+ return EditorLinkTheme(
128
+ fontFamily = json.optNullableString("fontFamily"),
129
+ fontSize = json.optNullableFloat("fontSize"),
130
+ fontWeight = json.optNullableString("fontWeight"),
131
+ fontStyle = json.optNullableString("fontStyle"),
132
+ color = parseColor(json.optNullableString("color")),
133
+ backgroundColor = parseColor(json.optNullableString("backgroundColor")),
134
+ underline = if (json.has("underline")) json.optBoolean("underline") else null
135
+ )
136
+ }
137
+ }
138
+
139
+ fun asTextStyle(): EditorTextStyle =
140
+ EditorTextStyle(
141
+ fontFamily = fontFamily,
142
+ fontSize = fontSize,
143
+ fontWeight = fontWeight,
144
+ fontStyle = fontStyle,
145
+ color = color
146
+ )
147
+ }
148
+
115
149
  data class EditorMentionTheme(
116
150
  val textColor: Int? = null,
117
151
  val backgroundColor: Int? = null,
@@ -268,6 +302,7 @@ data class EditorTheme(
268
302
  val list: EditorListTheme? = null,
269
303
  val horizontalRule: EditorHorizontalRuleTheme? = null,
270
304
  val mentions: EditorMentionTheme? = null,
305
+ val links: EditorLinkTheme? = null,
271
306
  val toolbar: EditorToolbarTheme? = null,
272
307
  val backgroundColor: Int? = null,
273
308
  val borderRadius: Float? = null,
@@ -298,6 +333,7 @@ data class EditorTheme(
298
333
  list = EditorListTheme.fromJson(root.optJSONObject("list")),
299
334
  horizontalRule = EditorHorizontalRuleTheme.fromJson(root.optJSONObject("horizontalRule")),
300
335
  mentions = EditorMentionTheme.fromJson(root.optJSONObject("mentions")),
336
+ links = EditorLinkTheme.fromJson(root.optJSONObject("links")),
301
337
  toolbar = EditorToolbarTheme.fromJson(root.optJSONObject("toolbar")),
302
338
  backgroundColor = parseColor(root.optNullableString("backgroundColor")),
303
339
  borderRadius = root.optNullableFloat("borderRadius"),
@@ -573,7 +573,7 @@ class NativeEditorExpoView(
573
573
  val mentions = addons.mentions ?: return
574
574
  val queryState = mentionQueryState ?: return
575
575
  val attrs = resolvedMentionAttrs(mentions.trigger, suggestion)
576
- if (mentions.resolveSelectionAttrs) {
576
+ if (mentions.resolveSelectionAttrs || mentions.resolveTheme) {
577
577
  emitMentionSelectRequest(mentions.trigger, suggestion, attrs, queryState)
578
578
  lastMentionEventJson = null
579
579
  clearMentionQueryState()
@@ -295,6 +295,16 @@ class NativeEditorModule : Module() {
295
295
  editorDestroy(editorId)
296
296
  }
297
297
  }
298
+ Function("measureContentHeight") { renderJson: String, themeJson: String?, width: Double ->
299
+ val density = appContext.reactContext?.resources?.displayMetrics?.density ?: 1f
300
+ val height = RenderBridge.measureHeight(
301
+ json = renderJson,
302
+ themeJson = themeJson,
303
+ width = width.toFloat(),
304
+ density = density
305
+ )
306
+ height.toDouble()
307
+ }
298
308
  Function("renderDocumentHtml") { configJson: String, html: String ->
299
309
  val editorId = editorCreate(configJson)
300
310
  try {
@@ -829,6 +829,53 @@ object RenderBridge {
829
829
  return state.result
830
830
  }
831
831
 
832
+ fun measureHeight(
833
+ json: String,
834
+ themeJson: String?,
835
+ width: Float,
836
+ density: Float
837
+ ): Float {
838
+ if (width <= 0) return 0f
839
+
840
+ val theme = EditorTheme.fromJson(themeJson)
841
+ val baseFontSize = theme?.text?.fontSize
842
+ ?: theme?.paragraph?.fontSize
843
+ ?: 16f
844
+
845
+ val spannable = buildSpannable(
846
+ json = json,
847
+ baseFontSize = baseFontSize,
848
+ textColor = android.graphics.Color.BLACK,
849
+ theme = theme,
850
+ density = density,
851
+ hostView = null
852
+ )
853
+
854
+ if (spannable.isEmpty()) return 0f
855
+
856
+ val contentInsets = theme?.contentInsets
857
+ val topInset = ((contentInsets?.top ?: 0f) * density).toInt()
858
+ val bottomInset = ((contentInsets?.bottom ?: 0f) * density).toInt()
859
+ val leftInset = ((contentInsets?.left ?: 0f) * density).toInt()
860
+ val rightInset = ((contentInsets?.right ?: 0f) * density).toInt()
861
+
862
+ val paint = android.text.TextPaint().apply {
863
+ textSize = baseFontSize * density
864
+ isAntiAlias = true
865
+ }
866
+
867
+ val availableWidth = (width - leftInset - rightInset).coerceAtLeast(0f).toInt()
868
+
869
+ val staticLayout = android.text.StaticLayout.Builder
870
+ .obtain(spannable, 0, spannable.length, paint, availableWidth)
871
+ .setAlignment(android.text.Layout.Alignment.ALIGN_NORMAL)
872
+ .setIncludePad(true)
873
+ .build()
874
+
875
+ val height = staticLayout.height + topInset + bottomInset
876
+ return height.toFloat()
877
+ }
878
+
832
879
  private fun appendElements(
833
880
  state: RenderBuildState,
834
881
  elements: JSONArray,
@@ -1135,59 +1182,77 @@ object RenderBridge {
1135
1182
  blockquoteDepth(blockStack) > 0
1136
1183
  )
1137
1184
  } ?: theme?.effectiveTextStyle("paragraph", inBlockquote = blockquoteDepth(blockStack) > 0)
1138
- val resolvedTextSize = textStyle?.fontSize?.times(density) ?: baseFontSize
1139
- val resolvedTextColor = textStyle?.color ?: textColor
1140
-
1141
- // Apply base styling.
1142
- builder.setSpan(
1143
- ForegroundColorSpan(resolvedTextColor),
1144
- start, end,
1145
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1146
- )
1147
- builder.setSpan(
1148
- AbsoluteSizeSpan(resolvedTextSize.toInt(), false),
1149
- start, end,
1150
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1151
- )
1152
1185
 
1153
1186
  // Determine which marks are active.
1154
- var hasBold = textStyle?.typefaceStyle()?.let { it == Typeface.BOLD || it == Typeface.BOLD_ITALIC } == true
1155
- var hasItalic = textStyle?.typefaceStyle()?.let { it == Typeface.ITALIC || it == Typeface.BOLD_ITALIC } == true
1156
- var hasUnderline = false
1187
+ var markBold = false
1188
+ var markItalic = false
1189
+ var markUnderline = false
1157
1190
  var hasStrike = false
1158
1191
  var hasCode = false
1192
+ var isLink = false
1193
+ var linkHref: String? = null
1159
1194
  for (mark in marks) {
1160
1195
  when {
1161
1196
  mark is String -> when (mark) {
1162
- "bold", "strong" -> hasBold = true
1163
- "italic", "em" -> hasItalic = true
1164
- "underline" -> hasUnderline = true
1197
+ "bold", "strong" -> markBold = true
1198
+ "italic", "em" -> markItalic = true
1199
+ "underline" -> markUnderline = true
1165
1200
  "strike", "strikethrough" -> hasStrike = true
1166
1201
  "code" -> hasCode = true
1167
1202
  }
1168
1203
  mark is JSONObject -> {
1169
1204
  val markType = mark.optString("type", "")
1170
1205
  if (markType == "link") {
1171
- hasUnderline = true
1172
- builder.setSpan(
1173
- ForegroundColorSpan(LayoutConstants.DEFAULT_LINK_COLOR),
1174
- start,
1175
- end,
1176
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1177
- )
1178
- val href = mark.optString("href", "")
1179
- if (href.isNotBlank()) {
1180
- builder.setSpan(
1181
- Annotation(NATIVE_LINK_HREF_ANNOTATION, href),
1182
- start,
1183
- end,
1184
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1185
- )
1186
- }
1206
+ isLink = true
1207
+ linkHref = mark.optString("href", "").takeIf { it.isNotBlank() }
1187
1208
  }
1188
1209
  }
1189
1210
  }
1190
1211
  }
1212
+ val linkTheme = if (isLink) theme?.links else null
1213
+ val effectiveTextStyle = textStyle?.mergedWith(linkTheme?.asTextStyle())
1214
+ ?: linkTheme?.asTextStyle()
1215
+ val resolvedTextSize = effectiveTextStyle?.fontSize?.times(density) ?: baseFontSize
1216
+ val resolvedTextColor = if (isLink) {
1217
+ effectiveTextStyle?.color ?: LayoutConstants.DEFAULT_LINK_COLOR
1218
+ } else {
1219
+ effectiveTextStyle?.color ?: textColor
1220
+ }
1221
+
1222
+ // Apply base styling.
1223
+ builder.setSpan(
1224
+ ForegroundColorSpan(resolvedTextColor),
1225
+ start, end,
1226
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1227
+ )
1228
+ builder.setSpan(
1229
+ AbsoluteSizeSpan(resolvedTextSize.toInt(), false),
1230
+ start, end,
1231
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1232
+ )
1233
+ linkTheme?.backgroundColor?.let { backgroundColor ->
1234
+ builder.setSpan(
1235
+ BackgroundColorSpan(backgroundColor),
1236
+ start,
1237
+ end,
1238
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1239
+ )
1240
+ }
1241
+ linkHref?.let { href ->
1242
+ builder.setSpan(
1243
+ Annotation(NATIVE_LINK_HREF_ANNOTATION, href),
1244
+ start,
1245
+ end,
1246
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1247
+ )
1248
+ }
1249
+
1250
+ val typefaceStyle = effectiveTextStyle?.typefaceStyle()
1251
+ val hasBold = markBold ||
1252
+ typefaceStyle?.let { it == Typeface.BOLD || it == Typeface.BOLD_ITALIC } == true
1253
+ val hasItalic = markItalic ||
1254
+ typefaceStyle?.let { it == Typeface.ITALIC || it == Typeface.BOLD_ITALIC } == true
1255
+ val hasUnderline = markUnderline || (isLink && (linkTheme?.underline ?: true))
1191
1256
 
1192
1257
  // Apply bold/italic as a combined StyleSpan.
1193
1258
  if (hasBold && hasItalic) {
@@ -1204,7 +1269,7 @@ object RenderBridge {
1204
1269
  )
1205
1270
  }
1206
1271
 
1207
- val fontFamily = textStyle?.fontFamily
1272
+ val fontFamily = effectiveTextStyle?.fontFamily
1208
1273
  if (!hasCode && !fontFamily.isNullOrBlank()) {
1209
1274
  builder.setSpan(
1210
1275
  TypefaceSpan(fontFamily),
@@ -26,6 +26,15 @@ export interface EditorTextStyle {
26
26
  lineHeight?: number;
27
27
  spacingAfter?: number;
28
28
  }
29
+ export interface EditorLinkTheme {
30
+ fontFamily?: string;
31
+ fontSize?: number;
32
+ fontWeight?: EditorFontWeight;
33
+ fontStyle?: EditorFontStyle;
34
+ color?: string;
35
+ backgroundColor?: string;
36
+ underline?: boolean;
37
+ }
29
38
  export interface EditorHeadingTheme {
30
39
  h1?: EditorTextStyle;
31
40
  h2?: EditorTextStyle;
@@ -85,6 +94,7 @@ export interface EditorTheme {
85
94
  list?: EditorListTheme;
86
95
  horizontalRule?: EditorHorizontalRuleTheme;
87
96
  mentions?: EditorMentionTheme;
97
+ links?: EditorLinkTheme;
88
98
  toolbar?: EditorToolbarTheme;
89
99
  backgroundColor?: string;
90
100
  borderRadius?: number;
@@ -14,6 +14,17 @@ export interface NativeProseViewerLinkPressEvent {
14
14
  text: string;
15
15
  }
16
16
  type NativeProseViewerContent = DocumentJSON | string;
17
+ export type NativeProseViewerMentionPrefix = string | ((mention: NativeProseViewerMentionRenderContext) => string | null | undefined);
18
+ export interface NativeProseViewerMentionsAddonConfig {
19
+ trigger?: string;
20
+ prefix?: NativeProseViewerMentionPrefix;
21
+ theme?: EditorMentionTheme;
22
+ resolveTheme?: (mention: NativeProseViewerMentionRenderContext) => EditorMentionTheme | null | undefined;
23
+ onPress?: (event: NativeProseViewerMentionPressEvent) => void;
24
+ }
25
+ export interface NativeProseViewerAddons {
26
+ mentions?: NativeProseViewerMentionsAddonConfig;
27
+ }
17
28
  interface NativeProseViewerBaseProps {
18
29
  contentRevision?: string | number;
19
30
  contentJSONRevision?: string | number;
@@ -23,10 +34,10 @@ interface NativeProseViewerBaseProps {
23
34
  allowBase64Images?: boolean;
24
35
  collapseTrailingEmptyParagraphs?: boolean;
25
36
  enableLinkTaps?: boolean;
26
- mentionPrefix?: string | ((mention: NativeProseViewerMentionRenderContext) => string | null | undefined);
27
- resolveMentionTheme?: (mention: NativeProseViewerMentionRenderContext) => EditorMentionTheme | null | undefined;
37
+ addons?: NativeProseViewerAddons;
28
38
  onPressLink?: (event: NativeProseViewerLinkPressEvent) => void;
29
- onPressMention?: (event: NativeProseViewerMentionPressEvent) => void;
39
+ contentId?: string;
40
+ containerWidth?: number;
30
41
  }
31
42
  interface NativeProseViewerJsonProps extends NativeProseViewerBaseProps {
32
43
  contentJSON: NativeProseViewerContent;
@@ -7,6 +7,7 @@ const expo_modules_core_1 = require("expo-modules-core");
7
7
  const react_native_1 = require("react-native");
8
8
  const addons_1 = require("./addons");
9
9
  const EditorTheme_1 = require("./EditorTheme");
10
+ const heightCache_1 = require("./heightCache");
10
11
  const schemas_1 = require("./schemas");
11
12
  const NativeProseViewerView = (0, expo_modules_core_1.requireNativeViewManager)('NativeEditor', 'NativeProseViewer');
12
13
  let nativeProseViewerModule = null;
@@ -62,18 +63,32 @@ function baseMentionLabelFromAttrs(attrs) {
62
63
  const label = attrs.label;
63
64
  return typeof label === 'string' && label.length > 0 ? label : 'mention';
64
65
  }
65
- function resolveMentionPrefix(mentionPrefix, mention) {
66
- const rawPrefix = typeof mentionPrefix === 'function' ? mentionPrefix(mention) : mentionPrefix;
66
+ function resolveConfiguredMentionPrefix(prefix, mention) {
67
+ const rawPrefix = typeof prefix === 'function' ? prefix(mention) : prefix;
67
68
  return typeof rawPrefix === 'string' && rawPrefix.length > 0 ? rawPrefix : undefined;
68
69
  }
70
+ function mentionTriggerFromAttrs(attrs) {
71
+ const trigger = attrs.mentionSuggestionChar;
72
+ return typeof trigger === 'string' && trigger.length > 0 ? trigger : undefined;
73
+ }
69
74
  function applyMentionPrefix(label, prefix) {
70
75
  if (!prefix || label.startsWith(prefix)) {
71
76
  return label;
72
77
  }
73
78
  return `${prefix}${label}`;
74
79
  }
75
- function collectMentionPayloadsByDocPos(document, mentionPrefix, resolveMentionTheme) {
80
+ function resolveMentionRenderedLabel(mentionContext, prefix, trigger) {
81
+ if (prefix !== undefined) {
82
+ return applyMentionPrefix(mentionContext.label, resolveConfiguredMentionPrefix(prefix, mentionContext));
83
+ }
84
+ return applyMentionPrefix(mentionContext.label, trigger ?? mentionTriggerFromAttrs(mentionContext.attrs));
85
+ }
86
+ function collectMentionPayloadsByDocPos(document, mentionsAddon) {
76
87
  const mentions = new Map();
88
+ const effectiveMentionPrefix = mentionsAddon?.prefix;
89
+ const effectiveResolveMentionTheme = mentionsAddon?.resolveTheme;
90
+ const defaultMentionTheme = mentionsAddon?.theme;
91
+ const trigger = mentionsAddon?.trigger?.trim() || undefined;
77
92
  const visit = (node, pos, isRoot = false) => {
78
93
  if (node == null || typeof node !== 'object') {
79
94
  return pos;
@@ -89,8 +104,14 @@ function collectMentionPayloadsByDocPos(document, mentionPrefix, resolveMentionT
89
104
  const attrs = normalizeMentionAttrs(nodeRecord);
90
105
  const label = baseMentionLabelFromAttrs(attrs);
91
106
  const mentionContext = { docPos: pos, label, attrs };
92
- const renderedLabel = applyMentionPrefix(label, resolveMentionPrefix(mentionPrefix, mentionContext));
93
- const mentionTheme = resolveMentionTheme?.(mentionContext) ?? undefined;
107
+ const renderedLabel = resolveMentionRenderedLabel(mentionContext, effectiveMentionPrefix, trigger);
108
+ const resolvedMentionTheme = effectiveResolveMentionTheme?.(mentionContext) ?? undefined;
109
+ const mentionTheme = defaultMentionTheme || resolvedMentionTheme
110
+ ? {
111
+ ...(defaultMentionTheme ?? {}),
112
+ ...(resolvedMentionTheme ?? {}),
113
+ }
114
+ : undefined;
94
115
  mentions.set(pos, {
95
116
  ...mentionContext,
96
117
  renderedLabel,
@@ -332,7 +353,8 @@ function extractRenderError(json) {
332
353
  }
333
354
  }
334
355
  function NativeProseViewer({ ...props }) {
335
- const { contentRevision, contentJSONRevision, schema, theme, style, allowBase64Images = false, collapseTrailingEmptyParagraphs = true, enableLinkTaps = true, mentionPrefix, resolveMentionTheme, onPressLink, onPressMention, } = props;
356
+ const { contentRevision, contentJSONRevision, schema, theme, style, allowBase64Images = false, collapseTrailingEmptyParagraphs = true, enableLinkTaps = true, addons, onPressLink, contentId, containerWidth, } = props;
357
+ const mentionPressHandler = addons?.mentions?.onPress;
336
358
  const contentJSON = 'contentJSON' in props ? props.contentJSON : undefined;
337
359
  const contentHTML = 'contentHTML' in props ? props.contentHTML : undefined;
338
360
  const resolvedContentRevision = contentRevision ?? contentJSONRevision;
@@ -349,7 +371,7 @@ function NativeProseViewer({ ...props }) {
349
371
  const themeJson = (0, react_1.useMemo)(() => (0, EditorTheme_1.serializeEditorTheme)(theme), [theme]);
350
372
  const mentionPayloadsByDocPos = (0, react_1.useMemo)(() => normalizedDocument == null
351
373
  ? new Map()
352
- : collectMentionPayloadsByDocPos(normalizedDocument, mentionPrefix, resolveMentionTheme), [mentionPrefix, normalizedDocument, resolveMentionTheme]);
374
+ : collectMentionPayloadsByDocPos(normalizedDocument, addons?.mentions), [addons?.mentions, normalizedDocument]);
353
375
  const renderJson = (0, react_1.useMemo)(() => {
354
376
  const configJson = JSON.stringify({
355
377
  schema: documentSchema,
@@ -382,6 +404,24 @@ function NativeProseViewer({ ...props }) {
382
404
  const renderJsonIsCollapsedEmpty = (0, react_1.useMemo)(() => collapseTrailingEmptyParagraphs &&
383
405
  renderElementsJsonContainsOnlyEmptyParagraphs(renderJson), [collapseTrailingEmptyParagraphs, renderJson]);
384
406
  const [contentHeight, setContentHeight] = (0, react_1.useState)(null);
407
+ (0, react_1.useEffect)(() => {
408
+ setContentHeight(null);
409
+ }, [contentId]);
410
+ const renderJsonHash = (0, react_1.useMemo)(() => (0, heightCache_1.computeRenderJsonHash)(renderJson), [renderJson]);
411
+ const layoutContextKey = (0, react_1.useMemo)(() => containerWidth != null ? (0, heightCache_1.computeLayoutContextKey)(themeJson, containerWidth) : null, [themeJson, containerWidth]);
412
+ const preMeasuredHeight = (0, react_1.useMemo)(() => {
413
+ if (!contentId || layoutContextKey == null || containerWidth == null) {
414
+ return null;
415
+ }
416
+ const cached = (0, heightCache_1.getHeightCache)(contentId, layoutContextKey, renderJsonHash);
417
+ if (cached != null)
418
+ return cached;
419
+ const measured = getNativeProseViewerModule().measureContentHeight(renderJson, themeJson, containerWidth);
420
+ if (measured > 0) {
421
+ (0, heightCache_1.setHeightCache)(contentId, layoutContextKey, renderJsonHash, measured);
422
+ }
423
+ return measured > 0 ? measured : null;
424
+ }, [contentId, containerWidth, renderJson, themeJson, layoutContextKey, renderJsonHash]);
385
425
  const handleContentHeightChange = (0, react_1.useCallback)((event) => {
386
426
  const density = react_native_1.Platform.OS === 'android' ? react_native_1.PixelRatio.get() : 1;
387
427
  const nextHeight = Math.ceil(event.nativeEvent.contentHeight / density);
@@ -394,18 +434,21 @@ function NativeProseViewer({ ...props }) {
394
434
  return;
395
435
  }
396
436
  setContentHeight((currentHeight) => currentHeight === nextHeight ? currentHeight : nextHeight);
397
- }, [renderJsonIsCollapsedEmpty]);
437
+ if (contentId && layoutContextKey != null) {
438
+ (0, heightCache_1.setHeightCache)(contentId, layoutContextKey, renderJsonHash, nextHeight);
439
+ }
440
+ }, [renderJsonIsCollapsedEmpty, contentId, layoutContextKey, renderJsonHash]);
398
441
  const handlePressMention = (0, react_1.useCallback)((event) => {
399
- if (!onPressMention)
442
+ if (!mentionPressHandler)
400
443
  return;
401
444
  const { docPos, label } = event.nativeEvent;
402
445
  const resolvedMention = mentionPayloadsByDocPos.get(docPos);
403
- onPressMention({
446
+ mentionPressHandler({
404
447
  docPos,
405
448
  label: resolvedMention?.renderedLabel ?? label,
406
449
  attrs: resolvedMention?.attrs ?? {},
407
450
  });
408
- }, [mentionPayloadsByDocPos, onPressMention]);
451
+ }, [mentionPayloadsByDocPos, mentionPressHandler]);
409
452
  const handlePressLink = (0, react_1.useCallback)((event) => {
410
453
  if (!onPressLink)
411
454
  return;
@@ -422,11 +465,16 @@ function NativeProseViewer({ ...props }) {
422
465
  else if (contentHeight != null && contentHeight > 0) {
423
466
  measuredStyle = { minHeight: contentHeight };
424
467
  }
468
+ else if (preMeasuredHeight != null && preMeasuredHeight > 0) {
469
+ measuredStyle = { minHeight: preMeasuredHeight };
470
+ }
425
471
  return [
426
472
  { minHeight: renderJsonIsCollapsedEmpty ? 0 : 1 },
427
473
  style,
428
474
  measuredStyle,
429
475
  ];
430
- }, [contentHeight, renderJsonIsCollapsedEmpty, style]);
431
- return ((0, jsx_runtime_1.jsx)(NativeProseViewerView, { style: nativeStyle, renderJson: renderJson, themeJson: themeJson, collapsesWhenEmpty: collapseTrailingEmptyParagraphs, enableLinkTaps: enableLinkTaps, interceptLinkTaps: typeof onPressLink === 'function', onContentHeightChange: handleContentHeightChange, onPressLink: typeof onPressLink === 'function' ? handlePressLink : undefined, onPressMention: typeof onPressMention === 'function' ? handlePressMention : undefined }));
476
+ }, [contentHeight, preMeasuredHeight, renderJsonIsCollapsedEmpty, style]);
477
+ return ((0, jsx_runtime_1.jsx)(NativeProseViewerView, { style: nativeStyle, renderJson: renderJson, themeJson: themeJson, collapsesWhenEmpty: collapseTrailingEmptyParagraphs, enableLinkTaps: enableLinkTaps, interceptLinkTaps: typeof onPressLink === 'function', onContentHeightChange: handleContentHeightChange, onPressLink: typeof onPressLink === 'function' ? handlePressLink : undefined, onPressMention: typeof mentionPressHandler === 'function'
478
+ ? handlePressMention
479
+ : undefined }));
432
480
  }
@@ -957,6 +957,22 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
957
957
  ? { ...selectionEvent.attrs, ...resolvedAttrs }
958
958
  : selectionEvent.attrs;
959
959
  }, []);
960
+ const resolveMentionInsertionAttrs = (0, react_1.useCallback)((selectionEvent) => {
961
+ const attrs = resolveMentionSelectionAttrs(selectionEvent);
962
+ let resolvedTheme;
963
+ try {
964
+ resolvedTheme = addonsRef.current?.mentions?.resolveTheme?.({
965
+ ...selectionEvent,
966
+ attrs,
967
+ });
968
+ }
969
+ catch (error) {
970
+ if (__DEV__) {
971
+ console.error('NativeRichTextEditor: mentions.resolveTheme threw', error);
972
+ }
973
+ }
974
+ return isRecord(resolvedTheme) ? { ...attrs, mentionTheme: resolvedTheme } : attrs;
975
+ }, [resolveMentionSelectionAttrs]);
960
976
  const handleInlineMentionSuggestionPress = (0, react_1.useCallback)((suggestion) => {
961
977
  const mentionQuery = mentionQueryEventRef.current;
962
978
  const mentions = addonsRef.current?.mentions;
@@ -966,7 +982,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
966
982
  bridgeRef.current.isDestroyed) {
967
983
  return;
968
984
  }
969
- const attrs = resolveMentionSelectionAttrs({
985
+ const attrs = resolveMentionInsertionAttrs({
970
986
  trigger: mentionQuery.trigger,
971
987
  suggestion,
972
988
  attrs: resolveMentionSuggestionAttrs(suggestion, mentionQuery.trigger),
@@ -981,7 +997,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
981
997
  attrs,
982
998
  });
983
999
  }
984
- }, [resolveMentionSelectionAttrs, runAndApply]);
1000
+ }, [resolveMentionInsertionAttrs, runAndApply]);
985
1001
  const handleAddonEvent = (0, react_1.useCallback)((event) => {
986
1002
  let parsed = null;
987
1003
  try {
@@ -1018,7 +1034,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1018
1034
  attrs: parsed.attrs,
1019
1035
  range: parsed.range,
1020
1036
  };
1021
- const finalAttrs = resolveMentionSelectionAttrs(selectionEvent);
1037
+ const finalAttrs = resolveMentionInsertionAttrs(selectionEvent);
1022
1038
  const update = runAndApply(() => bridgeRef.current?.insertContentJsonAtSelectionScalar(parsed.range.anchor, parsed.range.head, (0, addons_1.buildMentionFragmentJson)(finalAttrs)) ?? null);
1023
1039
  if (update) {
1024
1040
  addonsRef.current?.mentions?.onSelect?.({
@@ -1039,7 +1055,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
1039
1055
  attrs: parsed.attrs,
1040
1056
  });
1041
1057
  }
1042
- }, [resolveMentionSelectionAttrs, runAndApply]);
1058
+ }, [resolveMentionInsertionAttrs, runAndApply]);
1043
1059
  (0, react_1.useImperativeHandle)(ref, () => ({
1044
1060
  focus() {
1045
1061
  nativeViewRef.current?.focus?.();
package/dist/addons.d.ts CHANGED
@@ -31,11 +31,13 @@ export interface MentionSelectionAttrsEvent {
31
31
  head: number;
32
32
  };
33
33
  }
34
+ export type MentionThemeResolveEvent = MentionSelectionAttrsEvent;
34
35
  export interface MentionsAddonConfig {
35
36
  trigger?: string;
36
37
  suggestions?: readonly MentionSuggestion[];
37
38
  theme?: EditorMentionTheme;
38
39
  resolveSelectionAttrs?: (event: MentionSelectionAttrsEvent) => Record<string, unknown> | null | undefined;
40
+ resolveTheme?: (event: MentionThemeResolveEvent) => EditorMentionTheme | null | undefined;
39
41
  onQueryChange?: (event: MentionQueryChangeEvent) => void;
40
42
  onSelect?: (event: MentionSelectEvent) => void;
41
43
  }
@@ -53,6 +55,7 @@ export interface SerializedMentionsAddonConfig {
53
55
  trigger: string;
54
56
  theme?: EditorMentionTheme;
55
57
  resolveSelectionAttrs?: boolean;
58
+ resolveTheme?: boolean;
56
59
  suggestions: SerializedMentionSuggestion[];
57
60
  }
58
61
  export interface SerializedEditorAddons {
package/dist/addons.js CHANGED
@@ -57,6 +57,7 @@ function normalizeEditorAddons(addons) {
57
57
  ...(typeof addons.mentions.resolveSelectionAttrs === 'function'
58
58
  ? { resolveSelectionAttrs: true }
59
59
  : {}),
60
+ ...(typeof addons.mentions.resolveTheme === 'function' ? { resolveTheme: true } : {}),
60
61
  suggestions,
61
62
  },
62
63
  };
@@ -0,0 +1,6 @@
1
+ export declare const MAX_CACHE_SIZE = 500;
2
+ export declare function computeRenderJsonHash(input: string): number;
3
+ export declare function computeLayoutContextKey(themeJson: string | undefined, containerWidth: number): string;
4
+ export declare function getHeightCache(contentId: string, layoutContextKey: string, renderJsonHash: number): number | null;
5
+ export declare function setHeightCache(contentId: string, layoutContextKey: string, renderJsonHash: number, height: number): void;
6
+ export declare function clearHeightCache(): void;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_CACHE_SIZE = void 0;
4
+ exports.computeRenderJsonHash = computeRenderJsonHash;
5
+ exports.computeLayoutContextKey = computeLayoutContextKey;
6
+ exports.getHeightCache = getHeightCache;
7
+ exports.setHeightCache = setHeightCache;
8
+ exports.clearHeightCache = clearHeightCache;
9
+ exports.MAX_CACHE_SIZE = 500;
10
+ const cache = new Map();
11
+ function computeRenderJsonHash(input) {
12
+ let hash = 5381;
13
+ for (let i = 0; i < input.length; i++) {
14
+ hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0;
15
+ }
16
+ return hash >>> 0;
17
+ }
18
+ function computeLayoutContextKey(themeJson, containerWidth) {
19
+ return `${themeJson ?? ''}\x00${containerWidth}`;
20
+ }
21
+ function getHeightCache(contentId, layoutContextKey, renderJsonHash) {
22
+ const entry = cache.get(contentId);
23
+ if (!entry)
24
+ return null;
25
+ if (entry.layoutContextKey !== layoutContextKey ||
26
+ entry.renderJsonHash !== renderJsonHash) {
27
+ return null;
28
+ }
29
+ cache.delete(contentId);
30
+ cache.set(contentId, entry);
31
+ return entry.height;
32
+ }
33
+ function setHeightCache(contentId, layoutContextKey, renderJsonHash, height) {
34
+ cache.delete(contentId);
35
+ if (cache.size >= exports.MAX_CACHE_SIZE) {
36
+ const oldestKey = cache.keys().next().value;
37
+ if (oldestKey !== undefined) {
38
+ cache.delete(oldestKey);
39
+ }
40
+ }
41
+ cache.set(contentId, { height, renderJsonHash, layoutContextKey });
42
+ }
43
+ function clearHeightCache() {
44
+ cache.clear();
45
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  export { NativeRichTextEditor, type NativeRichTextEditorProps, type NativeRichTextEditorRef, type NativeRichTextEditorHeightBehavior, type NativeRichTextEditorToolbarPlacement, type RemoteSelectionDecoration, type LinkRequestContext, type ImageRequestContext, } from './NativeRichTextEditor';
2
- export { NativeProseViewer, type NativeProseViewerProps, type NativeProseViewerLinkPressEvent, type NativeProseViewerMentionRenderContext, type NativeProseViewerMentionPressEvent, } from './NativeProseViewer';
2
+ export { NativeProseViewer, type NativeProseViewerProps, type NativeProseViewerAddons, type NativeProseViewerMentionsAddonConfig, type NativeProseViewerMentionPrefix, type NativeProseViewerLinkPressEvent, type NativeProseViewerMentionRenderContext, type NativeProseViewerMentionPressEvent, } from './NativeProseViewer';
3
3
  export { EditorToolbar, DEFAULT_EDITOR_TOOLBAR_ITEMS, type EditorToolbarProps, type EditorToolbarItem, type EditorToolbarLeafItem, type EditorToolbarGroupChildItem, type EditorToolbarGroupItem, type EditorToolbarGroupPresentation, type EditorToolbarIcon, type EditorToolbarDefaultIconId, type EditorToolbarSFSymbolIcon, type EditorToolbarMaterialIcon, type EditorToolbarCommand, type EditorToolbarHeadingLevel, type EditorToolbarListType, } from './EditorToolbar';
4
- export type { EditorContentInsets, EditorTheme, EditorTextStyle, EditorHeadingTheme, EditorListTheme, EditorHorizontalRuleTheme, EditorMentionTheme, EditorToolbarTheme, EditorToolbarAppearance, EditorFontStyle, EditorFontWeight, } from './EditorTheme';
5
- export { MENTION_NODE_NAME, mentionNodeSpec, withMentionsSchema, buildMentionFragmentJson, type EditorAddons, type MentionsAddonConfig, type MentionSuggestion, type MentionQueryChangeEvent, type MentionSelectionAttrsEvent, type MentionSelectEvent, type EditorAddonEvent, } from './addons';
4
+ export type { EditorContentInsets, EditorTheme, EditorTextStyle, EditorLinkTheme, EditorHeadingTheme, EditorListTheme, EditorHorizontalRuleTheme, EditorMentionTheme, EditorToolbarTheme, EditorToolbarAppearance, EditorFontStyle, EditorFontWeight, } from './EditorTheme';
5
+ export { MENTION_NODE_NAME, mentionNodeSpec, withMentionsSchema, buildMentionFragmentJson, type EditorAddons, type MentionsAddonConfig, type MentionSuggestion, type MentionQueryChangeEvent, type MentionSelectionAttrsEvent, type MentionThemeResolveEvent, type MentionSelectEvent, type EditorAddonEvent, } from './addons';
6
6
  export { tiptapSchema, prosemirrorSchema, IMAGE_NODE_NAME, imageNodeSpec, withImagesSchema, buildImageFragmentJson, type SchemaDefinition, type NodeSpec, type MarkSpec, type AttrSpec, type ImageNodeAttributes, } from './schemas';
7
7
  export { createYjsCollaborationController, useYjsCollaboration, type YjsCollaborationOptions, type YjsCollaborationState, type YjsTransportStatus, type LocalAwarenessState, type LocalAwarenessUser, type UseYjsCollaborationResult, type YjsCollaborationController, } from './YjsCollaboration';
8
8
  export type { Selection, ActiveState, HistoryState, EditorUpdate, DocumentJSON, CollaborationPeer, EncodedCollaborationStateInput, } from './NativeEditorBridge';
9
9
  export { encodeCollaborationStateBase64, decodeCollaborationStateBase64, } from './NativeEditorBridge';
10
+ export { clearHeightCache } from './heightCache';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.decodeCollaborationStateBase64 = exports.encodeCollaborationStateBase64 = exports.useYjsCollaboration = exports.createYjsCollaborationController = exports.buildImageFragmentJson = exports.withImagesSchema = exports.imageNodeSpec = exports.IMAGE_NODE_NAME = exports.prosemirrorSchema = exports.tiptapSchema = exports.buildMentionFragmentJson = exports.withMentionsSchema = exports.mentionNodeSpec = exports.MENTION_NODE_NAME = exports.DEFAULT_EDITOR_TOOLBAR_ITEMS = exports.EditorToolbar = exports.NativeProseViewer = exports.NativeRichTextEditor = void 0;
3
+ exports.clearHeightCache = exports.decodeCollaborationStateBase64 = exports.encodeCollaborationStateBase64 = exports.useYjsCollaboration = exports.createYjsCollaborationController = exports.buildImageFragmentJson = exports.withImagesSchema = exports.imageNodeSpec = exports.IMAGE_NODE_NAME = exports.prosemirrorSchema = exports.tiptapSchema = exports.buildMentionFragmentJson = exports.withMentionsSchema = exports.mentionNodeSpec = exports.MENTION_NODE_NAME = exports.DEFAULT_EDITOR_TOOLBAR_ITEMS = exports.EditorToolbar = exports.NativeProseViewer = exports.NativeRichTextEditor = void 0;
4
4
  var NativeRichTextEditor_1 = require("./NativeRichTextEditor");
5
5
  Object.defineProperty(exports, "NativeRichTextEditor", { enumerable: true, get: function () { return NativeRichTextEditor_1.NativeRichTextEditor; } });
6
6
  var NativeProseViewer_1 = require("./NativeProseViewer");
@@ -26,3 +26,5 @@ Object.defineProperty(exports, "useYjsCollaboration", { enumerable: true, get: f
26
26
  var NativeEditorBridge_1 = require("./NativeEditorBridge");
27
27
  Object.defineProperty(exports, "encodeCollaborationStateBase64", { enumerable: true, get: function () { return NativeEditorBridge_1.encodeCollaborationStateBase64; } });
28
28
  Object.defineProperty(exports, "decodeCollaborationStateBase64", { enumerable: true, get: function () { return NativeEditorBridge_1.decodeCollaborationStateBase64; } });
29
+ var heightCache_1 = require("./heightCache");
30
+ Object.defineProperty(exports, "clearHeightCache", { enumerable: true, get: function () { return heightCache_1.clearHeightCache; } });
@@ -28,12 +28,14 @@ struct NativeMentionsAddonConfig {
28
28
  let suggestions: [NativeMentionSuggestion]
29
29
  let theme: EditorMentionTheme?
30
30
  let resolveSelectionAttrs: Bool
31
+ let resolveTheme: Bool
31
32
 
32
33
  init?(dictionary: [String: Any]) {
33
34
  let trigger = (dictionary["trigger"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
34
35
  self.trigger = (trigger?.isEmpty == false ? trigger : "@") ?? "@"
35
36
  self.suggestions = ((dictionary["suggestions"] as? [[String: Any]]) ?? []).compactMap(NativeMentionSuggestion.init(dictionary:))
36
37
  self.resolveSelectionAttrs = dictionary["resolveSelectionAttrs"] as? Bool ?? false
38
+ self.resolveTheme = dictionary["resolveTheme"] as? Bool ?? false
37
39
  if let theme = dictionary["theme"] as? [String: Any] {
38
40
  self.theme = EditorMentionTheme(dictionary: theme)
39
41
  } else {
@@ -124,6 +124,35 @@ struct EditorBlockquoteTheme {
124
124
  }
125
125
  }
126
126
 
127
+ struct EditorLinkTheme {
128
+ var fontFamily: String?
129
+ var fontSize: CGFloat?
130
+ var fontWeight: String?
131
+ var fontStyle: String?
132
+ var color: UIColor?
133
+ var backgroundColor: UIColor?
134
+ var underline: Bool?
135
+
136
+ init(dictionary: [String: Any]) {
137
+ fontFamily = dictionary["fontFamily"] as? String
138
+ fontSize = EditorTheme.cgFloat(dictionary["fontSize"])
139
+ fontWeight = dictionary["fontWeight"] as? String
140
+ fontStyle = dictionary["fontStyle"] as? String
141
+ color = EditorTheme.color(from: dictionary["color"])
142
+ backgroundColor = EditorTheme.color(from: dictionary["backgroundColor"])
143
+ underline = dictionary["underline"] as? Bool
144
+ }
145
+
146
+ func resolvedFont(fallback: UIFont) -> UIFont {
147
+ EditorTextStyle(
148
+ fontFamily: fontFamily,
149
+ fontSize: fontSize,
150
+ fontWeight: fontWeight,
151
+ fontStyle: fontStyle
152
+ ).resolvedFont(fallback: fallback)
153
+ }
154
+ }
155
+
127
156
  struct EditorMentionTheme {
128
157
  var textColor: UIColor?
129
158
  var backgroundColor: UIColor?
@@ -268,6 +297,7 @@ struct EditorTheme {
268
297
  var list: EditorListTheme?
269
298
  var horizontalRule: EditorHorizontalRuleTheme?
270
299
  var mentions: EditorMentionTheme?
300
+ var links: EditorLinkTheme?
271
301
  var toolbar: EditorToolbarTheme?
272
302
  var backgroundColor: UIColor?
273
303
  var borderRadius: CGFloat?
@@ -309,6 +339,9 @@ struct EditorTheme {
309
339
  if let mentions = dictionary["mentions"] as? [String: Any] {
310
340
  self.mentions = EditorMentionTheme(dictionary: mentions)
311
341
  }
342
+ if let links = dictionary["links"] as? [String: Any] {
343
+ self.links = EditorLinkTheme(dictionary: links)
344
+ }
312
345
  if let toolbar = dictionary["toolbar"] as? [String: Any] {
313
346
  self.toolbar = EditorToolbarTheme(dictionary: toolbar)
314
347
  }
@@ -2218,7 +2218,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
2218
2218
  }
2219
2219
 
2220
2220
  let attrs = resolvedMentionAttrs(trigger: mentions.trigger, suggestion: suggestion)
2221
- if mentions.resolveSelectionAttrs {
2221
+ if mentions.resolveSelectionAttrs || mentions.resolveTheme {
2222
2222
  emitMentionSelectRequest(
2223
2223
  trigger: mentions.trigger,
2224
2224
  suggestion: suggestion,
@@ -268,6 +268,14 @@ public class NativeEditorModule: Module {
268
268
  }
269
269
  return editorSetJson(id: editorId, json: json)
270
270
  }
271
+ Function("measureContentHeight") { (renderJson: String, themeJson: String?, width: Double) -> Double in
272
+ let height = RenderBridge.measureHeight(
273
+ forRenderJSON: renderJson,
274
+ themeJSON: themeJson,
275
+ width: CGFloat(width)
276
+ )
277
+ return Double(height)
278
+ }
271
279
  Function("renderDocumentHtml") { (configJson: String, html: String) -> String in
272
280
  let editorId = editorCreate(configJson: configJson)
273
281
  defer {
@@ -243,7 +243,8 @@ final class RenderBridge {
243
243
  let baseAttrs = attributesForMarks(
244
244
  marks,
245
245
  baseFont: blockFont,
246
- textColor: blockColor
246
+ textColor: blockColor,
247
+ theme: theme
247
248
  )
248
249
  let attrs = applyBlockStyle(
249
250
  to: baseAttrs,
@@ -541,6 +542,63 @@ final class RenderBridge {
541
542
  return result
542
543
  }
543
544
 
545
+ // MARK: - Height Pre-Measurement
546
+
547
+ static func measureHeight(
548
+ forRenderJSON renderJSON: String,
549
+ themeJSON: String?,
550
+ width: CGFloat
551
+ ) -> CGFloat {
552
+ guard width > 0 else { return 0 }
553
+
554
+ let theme = EditorTheme.from(json: themeJSON)
555
+ let baseFontSize = theme?.text?.fontSize ?? theme?.paragraph?.fontSize ?? 16
556
+ let baseFont = UIFont.systemFont(ofSize: baseFontSize)
557
+ let textColor = theme?.text?.color ?? UIColor.label
558
+
559
+ let attributedString = renderElements(
560
+ fromJSON: renderJSON,
561
+ baseFont: baseFont,
562
+ textColor: textColor,
563
+ theme: theme
564
+ )
565
+
566
+ guard attributedString.length > 0 else { return 0 }
567
+
568
+ let contentInsets = theme?.contentInsets
569
+ let topInset = contentInsets?.top ?? 0
570
+ let bottomInset = contentInsets?.bottom ?? 0
571
+ let leftInset = contentInsets?.left ?? 0
572
+ let rightInset = contentInsets?.right ?? 0
573
+
574
+ // When contentInsets are set, lineFragmentPadding is 0 (matches
575
+ // RichTextEditorView.theme didSet). Otherwise use the UITextView
576
+ // default of 5.
577
+ let lineFragmentPadding: CGFloat = contentInsets != nil ? 0 : 5
578
+
579
+ let textStorage = NSTextStorage(attributedString: attributedString)
580
+ let layoutManager = NSLayoutManager()
581
+ let containerWidth = width - leftInset - rightInset - lineFragmentPadding * 2
582
+ let textContainer = NSTextContainer(
583
+ size: CGSize(width: max(containerWidth, 0), height: .greatestFiniteMagnitude)
584
+ )
585
+ textContainer.lineFragmentPadding = 0
586
+
587
+ layoutManager.addTextContainer(textContainer)
588
+ textStorage.addLayoutManager(layoutManager)
589
+
590
+ layoutManager.ensureLayout(for: textContainer)
591
+
592
+ var usedRect = layoutManager.usedRect(for: textContainer)
593
+ let extraLineFragmentRect = layoutManager.extraLineFragmentRect
594
+ if !extraLineFragmentRect.isEmpty {
595
+ usedRect = usedRect.union(extraLineFragmentRect)
596
+ }
597
+
598
+ let height = ceil(usedRect.height + topInset + bottomInset)
599
+ return height
600
+ }
601
+
544
602
  // MARK: - Mark Handling
545
603
 
546
604
  /// Build NSAttributedString attributes for a set of render marks.
@@ -556,7 +614,8 @@ final class RenderBridge {
556
614
  static func attributesForMarks(
557
615
  _ marks: [Any],
558
616
  baseFont: UIFont,
559
- textColor: UIColor
617
+ textColor: UIColor,
618
+ theme: EditorTheme? = nil
560
619
  ) -> [NSAttributedString.Key: Any] {
561
620
  var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
562
621
 
@@ -566,6 +625,8 @@ final class RenderBridge {
566
625
 
567
626
  var traits: UIFontDescriptor.SymbolicTraits = []
568
627
  var useMonospace = false
628
+ var linkTheme: EditorLinkTheme?
629
+ var shouldUnderline = false
569
630
  for mark in marks {
570
631
  let markObject = mark as? [String: Any]
571
632
  let markType: String
@@ -583,14 +644,20 @@ final class RenderBridge {
583
644
  case "italic", "em":
584
645
  traits.insert(.traitItalic)
585
646
  case "underline":
586
- attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
647
+ shouldUnderline = true
587
648
  case "strike", "strikethrough":
588
649
  attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
589
650
  case "code":
590
651
  useMonospace = true
591
652
  case "link":
592
- attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
593
- attrs[.foregroundColor] = UIColor.systemBlue
653
+ linkTheme = theme?.links
654
+ if theme?.links?.underline ?? true {
655
+ shouldUnderline = true
656
+ }
657
+ attrs[.foregroundColor] = theme?.links?.color ?? UIColor.systemBlue
658
+ if let backgroundColor = theme?.links?.backgroundColor {
659
+ attrs[.backgroundColor] = backgroundColor
660
+ }
594
661
  if let href = markObject?["href"] as? String, !href.isEmpty {
595
662
  attrs[RenderBridgeAttributes.linkHref] = href
596
663
  }
@@ -599,11 +666,11 @@ final class RenderBridge {
599
666
  }
600
667
  }
601
668
 
602
- var resolvedFont = baseFont
669
+ var resolvedFont = linkTheme?.resolvedFont(fallback: baseFont) ?? baseFont
603
670
 
604
671
  if useMonospace {
605
672
  resolvedFont = UIFont.monospacedSystemFont(
606
- ofSize: baseFont.pointSize,
673
+ ofSize: resolvedFont.pointSize,
607
674
  weight: traits.contains(.traitBold) ? .bold : .regular
608
675
  )
609
676
  // Monospaced doesn't support italic via descriptor traits, but we
@@ -612,15 +679,19 @@ final class RenderBridge {
612
679
  if traits.contains(.traitItalic) && !traits.contains(.traitBold) {
613
680
  // For code+italic only, try applying italic trait.
614
681
  if let descriptor = resolvedFont.fontDescriptor.withSymbolicTraits(.traitItalic) {
615
- resolvedFont = UIFont(descriptor: descriptor, size: baseFont.pointSize)
682
+ resolvedFont = UIFont(descriptor: descriptor, size: resolvedFont.pointSize)
616
683
  }
617
684
  }
618
685
  } else if !traits.isEmpty {
619
- if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
620
- resolvedFont = UIFont(descriptor: descriptor, size: baseFont.pointSize)
686
+ let mergedTraits = resolvedFont.fontDescriptor.symbolicTraits.union(traits)
687
+ if let descriptor = resolvedFont.fontDescriptor.withSymbolicTraits(mergedTraits) {
688
+ resolvedFont = UIFont(descriptor: descriptor, size: resolvedFont.pointSize)
621
689
  }
622
690
  }
623
691
 
692
+ if shouldUnderline {
693
+ attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
694
+ }
624
695
  attrs[.font] = resolvedFont
625
696
  return attrs
626
697
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollohg/react-native-prose-editor",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Native rich text editor with Rust core for React Native",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/apollohg/react-native-prose-editor",