diff --git a/.maestro/enrichedInput/screenshots/android/custom_style_fonts.png b/.maestro/enrichedInput/screenshots/android/custom_style_fonts.png new file mode 100644 index 000000000..df56ea457 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/custom_style_fonts.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png index a4a3d608e..8f6adef9b 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png index d257ce783..6bbb64a1e 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png index 44d2d1b42..eb601c3e2 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedText/screenshots/android/custom_style_fonts_visual.png b/.maestro/enrichedText/screenshots/android/custom_style_fonts_visual.png new file mode 100644 index 000000000..e69b9cec8 Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/custom_style_fonts_visual.png differ diff --git a/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt b/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt index ef8ced020..76e8980ce 100644 --- a/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt @@ -3,4 +3,12 @@ package com.swmansion.enriched.common data class CustomStyle( val foregroundColor: Int? = null, val backgroundColor: Int? = null, -) + val fontSize: Float? = null, + val fontFamily: String? = null, +) { + fun isEmpty(): Boolean = + foregroundColor == null && + backgroundColor == null && + (fontSize == null || fontSize <= 0f) && + fontFamily.isNullOrBlank() +} diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java index 092a221c0..6fb127657 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java @@ -349,7 +349,12 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, EnrichedCustomStyleSpan cs = (EnrichedCustomStyleSpan) style[j]; Integer fgColor = cs.getForegroundColor(); Integer bgColor = cs.getBackgroundColor(); - if (fgColor != null || bgColor != null) { + Float fontSize = cs.getFontSize(); + String fontFamily = cs.getFontFamily(); + if (fgColor != null + || bgColor != null + || fontSize != null + || (fontFamily != null && !fontFamily.isEmpty())) { StringBuilder cssProps = new StringBuilder(); if (fgColor != null) { cssProps @@ -364,6 +369,18 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, .append(EnrichedColorParser.colorToHex(bgColor)) .append(";"); } + if (fontSize != null) { + if (cssProps.length() > 0) cssProps.append(" "); + cssProps.append("font-size: ").append(formatCssFontSizeValue(fontSize)).append("px;"); + } + if (fontFamily != null && !fontFamily.isEmpty()) { + if (cssProps.length() > 0) cssProps.append(" "); + if (fontFamily.indexOf(' ') >= 0) { + cssProps.append("font-family: '").append(fontFamily).append("';"); + } else { + cssProps.append("font-family: ").append(fontFamily).append(";"); + } + } out.append(""); } else { out.append(""); @@ -434,6 +451,13 @@ private static void withinStyle(StringBuilder out, CharSequence text, int start, } } } + + private static String formatCssFontSizeValue(float fontSize) { + if (fontSize == Math.rint(fontSize) && !Float.isInfinite(fontSize)) { + return String.valueOf((int) fontSize); + } + return String.valueOf(fontSize); + } } class HtmlToSpannedConverter implements ContentHandler { @@ -458,6 +482,13 @@ class HtmlToSpannedConverter implements ContentHandler { private static final Pattern CSS_BG_PATTERN = Pattern.compile("background-color\\s*:\\s*([^;]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern CSS_FONT_SIZE_PATTERN = + Pattern.compile( + "font-size\\s*:\\s*([0-9.]+)(?:\\s*px)?(?=\\s*;|\\s*$)", Pattern.CASE_INSENSITIVE); + + private static final Pattern CSS_FONT_FAMILY_PATTERN = + Pattern.compile("font-family\\s*:\\s*([^;]+)", Pattern.CASE_INSENSITIVE); + private static String parseCssAlignmentValue(Attributes attributes) { String style = attributes.getValue("", "style"); if (style == null) return null; @@ -963,6 +994,8 @@ private static void startSpan(Editable text, Attributes attributes) { String styleAttr = attributes.getValue("", "style"); Integer fg = null; Integer bg = null; + Float fontSize = null; + String fontFamily = null; if (styleAttr != null) { Matcher fgMatcher = CSS_FG_PATTERN.matcher(styleAttr); @@ -973,17 +1006,44 @@ private static void startSpan(Editable text, Attributes attributes) { if (bgMatcher.find()) { bg = EnrichedColorParser.parseCssColor(bgMatcher.group(1)); } + Matcher fontSizeMatcher = CSS_FONT_SIZE_PATTERN.matcher(styleAttr); + if (fontSizeMatcher.find()) { + String fontSizeString = fontSizeMatcher.group(1); + if (fontSizeString != null) { + fontSize = Float.parseFloat(fontSizeString); + } + } + Matcher fontFamilyMatcher = CSS_FONT_FAMILY_PATTERN.matcher(styleAttr); + if (fontFamilyMatcher.find()) { + fontFamily = parseCssFontFamily(fontFamilyMatcher.group(1)); + } } - if (fg != null || bg != null) { - start(text, new CustomStyleMark(fg, bg)); + if (fg != null + || bg != null + || fontSize != null + || (fontFamily != null && !fontFamily.isEmpty())) { + start(text, new CustomStyleMark(fg, bg, fontSize, fontFamily)); } } + private static String parseCssFontFamily(String raw) { + if (raw == null) return null; + String value = raw.trim(); + if ((value.startsWith("'") && value.endsWith("'")) + || (value.startsWith("\"") && value.endsWith("\""))) { + value = value.substring(1, value.length() - 1); + } + return value.isEmpty() ? null : value; + } + private static void endSpan(Editable text, T style, EnrichedSpanFactory spanFactory) { CustomStyleMark mark = getLast(text, CustomStyleMark.class); if (mark == null) return; - setSpanFromMark(text, mark, spanFactory.createCustomStyleSpan(mark.mFg, mark.mBg)); + setSpanFromMark( + text, + mark, + spanFactory.createCustomStyleSpan(mark.mFg, mark.mBg, mark.mFontSize, mark.mFontFamily)); } public void setDocumentLocator(Locator locator) {} @@ -1116,10 +1176,14 @@ public Newline(int numNewlines) { private static class CustomStyleMark { public final Integer mFg; public final Integer mBg; + public final Float mFontSize; + public final String mFontFamily; - public CustomStyleMark(Integer fg, Integer bg) { + public CustomStyleMark(Integer fg, Integer bg, Float fontSize, String fontFamily) { mFg = fg; mBg = bg; + mFontSize = fontSize; + mFontFamily = fontFamily; } } diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt index 7e6c818a7..30c300c7a 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt @@ -85,5 +85,7 @@ interface EnrichedSpanFactory { fun createCustomStyleSpan( foregroundColor: Int?, backgroundColor: Int?, + fontSize: Float?, + fontFamily: String?, ): EnrichedCustomStyleSpan } diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt index 139cb54c4..5929c729a 100644 --- a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt @@ -1,22 +1,68 @@ package com.swmansion.enriched.common.spans +import android.content.res.AssetManager import android.graphics.Color import android.text.TextPaint -import android.text.style.CharacterStyle +import android.text.style.MetricAffectingSpan +import com.facebook.react.common.ReactConstants +import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles +import com.swmansion.enriched.common.CustomStyle +import com.swmansion.enriched.common.pixelFromSpOrDp import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan open class EnrichedCustomStyleSpan( private val foregroundColor: Int?, private val backgroundColor: Int?, -) : CharacterStyle(), + private val fontSizeSp: Float?, + private val fontFamily: String?, + private val assets: AssetManager, + private val allowFontScaling: Boolean, +) : MetricAffectingSpan(), EnrichedInlineSpan { fun getForegroundColor(): Int? = foregroundColor fun getBackgroundColor(): Int? = backgroundColor + fun getFontSize(): Float? = fontSizeSp + + fun getFontFamily(): String? = fontFamily + + fun toCustomStyle(): CustomStyle = + CustomStyle( + foregroundColor = foregroundColor, + backgroundColor = backgroundColor, + fontSize = fontSizeSp, + fontFamily = fontFamily, + ) + + protected fun getAssets(): AssetManager = assets + + protected fun getAllowFontScaling(): Boolean = allowFontScaling + + override fun updateMeasureState(textPaint: TextPaint) { + applyFontState(textPaint) + } + override fun updateDrawState(textPaint: TextPaint) { foregroundColor?.let { textPaint.color = it } backgroundColor?.let { textPaint.bgColor = withOpacity(it, 80) } + applyFontState(textPaint) + } + + private fun applyFontState(textPaint: TextPaint) { + fontFamily?.trim()?.takeIf { it.isNotEmpty() }?.let { family -> + textPaint.typeface = + applyStyles( + textPaint.typeface, + ReactConstants.UNSET, + ReactConstants.UNSET, + family, + assets, + ) + } + fontSizeSp?.takeIf { it > 0f }?.let { size -> + textPaint.textSize = pixelFromSpOrDp(size, allowFontScaling) + } } private fun withOpacity( diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt index 61f5cbf2b..4e1dd397b 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt @@ -1,5 +1,6 @@ package com.swmansion.enriched.text +import android.content.res.AssetManager import com.swmansion.enriched.common.parser.EnrichedSpanFactory import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.text.spans.EnrichedTextAlignmentSpan @@ -24,7 +25,10 @@ import com.swmansion.enriched.text.spans.EnrichedTextStrikeThroughSpan import com.swmansion.enriched.text.spans.EnrichedTextUnderlineSpan import com.swmansion.enriched.text.spans.EnrichedTextUnorderedListSpan -class EnrichedTextSpanFactory : EnrichedSpanFactory { +class EnrichedTextSpanFactory( + private val assets: AssetManager, + private val allowFontScaling: Boolean, +) : EnrichedSpanFactory { override fun createAlignmentSpan(cssValue: String) = EnrichedTextAlignmentSpan(cssValue) override fun createBoldSpan(style: EnrichedTextStyle) = EnrichedTextBoldSpan(style) @@ -87,5 +91,15 @@ class EnrichedTextSpanFactory : EnrichedSpanFactory { override fun createCustomStyleSpan( foregroundColor: Int?, backgroundColor: Int?, - ): EnrichedCustomStyleSpan = EnrichedTextCustomStyleSpan(foregroundColor, backgroundColor) + fontSize: Float?, + fontFamily: String?, + ): EnrichedCustomStyleSpan = + EnrichedTextCustomStyleSpan( + foregroundColor, + backgroundColor, + fontSize, + fontFamily, + assets, + allowFontScaling, + ) } diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt index 53a0a503a..cabf92775 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt @@ -46,12 +46,17 @@ class EnrichedTextView : AppCompatTextView { set(value) { if (field == value) return field = value + // Invalidate the spannable factory so that it is recreated with the new allowFontScaling value + cachedSpannableFactory = null fontSizeRaw?.let { setFontSize(it) } htmlStyleMap?.let { setHtmlStyle(it) } } private var enrichedStyle: EnrichedTextStyle? = null - private val spannableFactory = EnrichedTextSpanFactory() + + private var cachedSpannableFactory: EnrichedTextSpanFactory? = null + private val spannableFactory: EnrichedTextSpanFactory + get() = cachedSpannableFactory ?: EnrichedTextSpanFactory(context.assets, allowFontScaling).also { cachedSpannableFactory = it } // We keep the parsedText around so that when an async image finishes loading we can re-call // setText with the same instance and force the TextView to rebuild its layout. diff --git a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt index b82c75212..2f01aa739 100644 --- a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt @@ -118,7 +118,7 @@ object MeasurementStore { val allowFontScaling = allowFontScalingFromProps(props) val enrichedStyle = EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize, style, allowFontScaling) - val factory = EnrichedTextSpanFactory() + val factory = EnrichedTextSpanFactory(context.assets, allowFontScaling) val parsed = EnrichedParser.fromHtml(textToParse, enrichedStyle, factory) return parsed.trimEnd('\n') } catch (e: Exception) { diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt index f948be2dc..fdb6566b6 100644 --- a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt @@ -1,5 +1,6 @@ package com.swmansion.enriched.text.spans +import android.content.res.AssetManager import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.text.EnrichedTextStyle import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan @@ -7,10 +8,28 @@ import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan class EnrichedTextCustomStyleSpan( foregroundColor: Int?, backgroundColor: Int?, -) : EnrichedCustomStyleSpan(foregroundColor, backgroundColor), + fontSize: Float?, + fontFamily: String?, + assets: AssetManager, + allowFontScaling: Boolean, +) : EnrichedCustomStyleSpan( + foregroundColor, + backgroundColor, + fontSize, + fontFamily, + assets, + allowFontScaling, + ), EnrichedTextSpan { override val dependsOnHtmlStyle: Boolean = false override fun rebuildWithStyle(style: EnrichedTextStyle): EnrichedTextCustomStyleSpan = - EnrichedTextCustomStyleSpan(getForegroundColor(), getBackgroundColor()) + EnrichedTextCustomStyleSpan( + getForegroundColor(), + getBackgroundColor(), + getFontSize(), + getFontFamily(), + getAssets(), + getAllowFontScaling(), + ) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt index f4eb73166..6a60cce25 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt @@ -1,6 +1,8 @@ package com.swmansion.enriched.textinput +import android.content.res.AssetManager import com.swmansion.enriched.common.parser.EnrichedSpanFactory +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.common.spans.EnrichedImageSpan import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan import com.swmansion.enriched.textinput.spans.EnrichedInputBlockQuoteSpan @@ -25,7 +27,10 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputUnderlineSpan import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan import com.swmansion.enriched.textinput.styles.HtmlStyle -class EnrichedTextInputSpannableFactory : EnrichedSpanFactory { +class EnrichedTextInputSpannableFactory( + private val assets: AssetManager, + private val allowFontScaling: Boolean, +) : EnrichedSpanFactory { override fun createAlignmentSpan(cssValue: String) = EnrichedInputAlignmentSpan(cssValue) override fun createBoldSpan(style: HtmlStyle) = EnrichedInputBoldSpan(style) @@ -88,5 +93,15 @@ class EnrichedTextInputSpannableFactory : EnrichedSpanFactory { override fun createCustomStyleSpan( foregroundColor: Int?, backgroundColor: Int?, - ) = EnrichedInputCustomStyleSpan(foregroundColor, backgroundColor) + fontSize: Float?, + fontFamily: String?, + ): EnrichedCustomStyleSpan = + EnrichedInputCustomStyleSpan( + foregroundColor, + backgroundColor, + fontSize, + fontFamily, + assets, + allowFontScaling, + ) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index 71a3782e6..f65a7d6ad 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -100,6 +100,8 @@ class EnrichedTextInputView : set(value) { if (field != value) { field = value + // Invalidate the spannable factory so that it is recreated with the new allowFontScaling value + cachedSpannableFactory = null val raw = fontSizeRaw if (raw != null) { setFontSize(raw) // re-invokes invalidateStyles internally @@ -148,7 +150,13 @@ class EnrichedTextInputView : private var defaultValueDirty: Boolean = false private var inputMethodManager: InputMethodManager? = null - private val spannableFactory = EnrichedTextInputSpannableFactory() + + private var cachedSpannableFactory: EnrichedTextInputSpannableFactory? = null + private val spannableFactory: EnrichedTextInputSpannableFactory + get() = + cachedSpannableFactory + ?: EnrichedTextInputSpannableFactory(context.assets, allowFontScaling).also { cachedSpannableFactory = it } + private var contextMenuItems: List> = emptyList() constructor(context: Context) : super(context) { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt index 09363e08a..4bbc5d0bf 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt @@ -116,7 +116,11 @@ object MeasurementStore { try { val htmlStyle = HtmlStyle(defaultView, props.getMap("htmlStyle")) - val factory = EnrichedTextInputSpannableFactory() + val factory = + EnrichedTextInputSpannableFactory( + defaultView.context.assets, + allowFontScalingFromProps(props), + ) val parsed = EnrichedParser.fromHtml(defaultValue, htmlStyle, factory) return parsed.trimEnd('\n') } catch (e: Exception) { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt index 5257a40ba..da78595cb 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt @@ -1,5 +1,6 @@ package com.swmansion.enriched.textinput.spans +import android.content.res.AssetManager import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan import com.swmansion.enriched.textinput.styles.HtmlStyle @@ -7,10 +8,28 @@ import com.swmansion.enriched.textinput.styles.HtmlStyle class EnrichedInputCustomStyleSpan( foregroundColor: Int?, backgroundColor: Int?, -) : EnrichedCustomStyleSpan(foregroundColor, backgroundColor), + fontSize: Float?, + fontFamily: String?, + assets: AssetManager, + allowFontScaling: Boolean, +) : EnrichedCustomStyleSpan( + foregroundColor, + backgroundColor, + fontSize, + fontFamily, + assets, + allowFontScaling, + ), EnrichedInputSpan { override val dependsOnHtmlStyle: Boolean = false override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedInputCustomStyleSpan = - EnrichedInputCustomStyleSpan(getForegroundColor(), getBackgroundColor()) + EnrichedInputCustomStyleSpan( + getForegroundColor(), + getBackgroundColor(), + getFontSize(), + getFontFamily(), + getAssets(), + getAllowFontScaling(), + ) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt index 3c94f1589..6782418ff 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt @@ -2,6 +2,7 @@ package com.swmansion.enriched.textinput.styles import android.text.Editable import android.text.Spannable +import com.swmansion.enriched.common.CustomStyle import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.spans.EnrichedInputCustomStyleSpan @@ -18,19 +19,43 @@ class CustomStyles( val hasFg = json.has("foregroundColor") val hasBg = json.has("backgroundColor") + val hasFontSize = json.has("fontSize") + val hasFontFamily = json.has("fontFamily") val fgColor = if (hasFg && !json.isNull("foregroundColor")) json.getInt("foregroundColor") else null val bgColor = if (hasBg && !json.isNull("backgroundColor")) json.getInt("backgroundColor") else null + val fontSize = + if (hasFontSize && !json.isNull("fontSize")) { + json.getDouble("fontSize").toFloat().takeIf { it > 0f } + } else { + null + } + val fontFamily = + if (hasFontFamily && !json.isNull("fontFamily")) { + json.getString("fontFamily").trim().takeIf { it.isNotEmpty() } + } else { + null + } - if (start == end) { - val currentStyle = view.spanState?.customStyle - val finalFg = if (hasFg) fgColor else currentStyle?.foregroundColor - val finalBg = if (hasBg) bgColor else currentStyle?.backgroundColor + val styleUpdater: (CustomStyle) -> CustomStyle = { oldStyle -> + CustomStyle( + foregroundColor = if (hasFg) fgColor else oldStyle.foregroundColor, + backgroundColor = if (hasBg) bgColor else oldStyle.backgroundColor, + fontSize = if (hasFontSize) fontSize else oldStyle.fontSize, + fontFamily = if (hasFontFamily) fontFamily else oldStyle.fontFamily, + ) + } - view.spanState?.setCustomStyle(finalFg, finalBg) + if (start == end) { + val merged = styleUpdater(view.spanState?.customStyle ?: CustomStyle()) + view.spanState?.setCustomStyle( + merged.foregroundColor, + merged.backgroundColor, + merged.fontSize, + merged.fontFamily, + ) } else { - val spannable = view.text as Spannable - applyCustomStyleSpan(spannable, start, end, hasFg, fgColor, hasBg, bgColor) + applyCustomStyleSpan(view.text as Spannable, start, end, styleUpdater) view.selection.validateStyles() } } @@ -39,10 +64,7 @@ class CustomStyles( spannable: Spannable, start: Int, end: Int, - hasFg: Boolean, - fgColor: Int?, - hasBg: Boolean, - bgColor: Int?, + styleUpdater: (CustomStyle) -> CustomStyle, ) { val existingSpans = spannable.getSpans(start, end, EnrichedInputCustomStyleSpan::class.java) val boundaries = mutableSetOf(start, end) @@ -57,13 +79,12 @@ class CustomStyles( // Remove old spans, restore outer edges, and collect internal boundaries for ((span, spanStart, spanEnd) in oldSpans) { - val spanFg = span.getForegroundColor() - val spanBg = span.getBackgroundColor() + val style = span.toCustomStyle() spannable.removeSpan(span) - if (spanStart < start) setCustomSpan(spannable, spanStart, start, spanFg, spanBg) - if (spanEnd > end) setCustomSpan(spannable, end, spanEnd, spanFg, spanBg) + if (spanStart < start) setCustomSpan(spannable, spanStart, start, style) + if (spanEnd > end) setCustomSpan(spannable, end, spanEnd, style) if (spanStart in start..end) boundaries.add(spanStart) if (spanEnd in start..end) boundaries.add(spanEnd) @@ -78,11 +99,10 @@ class CustomStyles( // Find the old span that fully covers this specific chunk val oldSpan = oldSpans.firstOrNull { it.second <= chunkStart && it.third >= chunkEnd }?.first + val oldStyle = oldSpan?.toCustomStyle() ?: CustomStyle() + val merged = styleUpdater(oldStyle) - val finalFg = if (hasFg) fgColor else oldSpan?.getForegroundColor() - val finalBg = if (hasBg) bgColor else oldSpan?.getBackgroundColor() - - setCustomSpan(spannable, chunkStart, chunkEnd, finalFg, finalBg) + setCustomSpan(spannable, chunkStart, chunkEnd, merged) } } @@ -92,19 +112,17 @@ class CustomStyles( endCursorPosition: Int, ) { val isInsertion = endCursorPosition > startCursorPosition - val activeStyle = view.spanState?.customStyle if (isInsertion) { - val activeFg = activeStyle?.foregroundColor - val activeBg = activeStyle?.backgroundColor + val activeStyle = view.spanState?.customStyle ?: CustomStyle() - // Split existing spans if they don't match the current active colors - splitCustomSpanOnInsertion(s, startCursorPosition, endCursorPosition, activeFg, activeBg) + // Split existing spans if they don't match the current active values + splitCustomSpanOnInsertion(s, startCursorPosition, endCursorPosition, activeStyle) - setCustomSpan(s, startCursorPosition, endCursorPosition, activeFg, activeBg) + setCustomSpan(s, startCursorPosition, endCursorPosition, activeStyle) } - // Merge any adjacent spans that have the exact same colors + // Merge any adjacent spans that have the exact same style collapseAdjacentCustomSpans(s, startCursorPosition, endCursorPosition) } @@ -112,8 +130,7 @@ class CustomStyles( spannable: Spannable, insertStart: Int, insertEnd: Int, - activeFg: Int?, - activeBg: Int?, + activeStyle: CustomStyle, ) { val spans = spannable.getSpans(insertStart, insertEnd, EnrichedInputCustomStyleSpan::class.java) @@ -122,20 +139,19 @@ class CustomStyles( val spanEnd = spannable.getSpanEnd(span) if (spanStart < 0 || spanEnd < 0) continue - val spanFg = span.getForegroundColor() - val spanBg = span.getBackgroundColor() + val spanStyle = span.toCustomStyle() // If the existing span perfectly matches the active state, leave it - if (spanFg == activeFg && spanBg == activeBg) continue + if (spanStyle == activeStyle) continue - // Colors differ. We must split the old span so it doesn't cover the new text + // Spans differ. We must split the old span so it doesn't cover the new text spannable.removeSpan(span) if (spanStart < insertStart) { - setCustomSpan(spannable, spanStart, insertStart, spanFg, spanBg) + setCustomSpan(spannable, spanStart, insertStart, spanStyle) } if (spanEnd > insertEnd) { - setCustomSpan(spannable, insertEnd, spanEnd, spanFg, spanBg) + setCustomSpan(spannable, insertEnd, spanEnd, spanStyle) } } } @@ -165,44 +181,48 @@ class CustomStyles( sortedSpans.forEach { spannable.removeSpan(it.first) } var (_, currentStart, currentEnd) = sortedSpans[0] - var currentFg = sortedSpans[0].first.getForegroundColor() - var currentBg = sortedSpans[0].first.getBackgroundColor() + var currentStyle = sortedSpans[0].first.toCustomStyle() // Iterate and merge for (i in 1 until sortedSpans.size) { val (span, spanStart, spanEnd) = sortedSpans[i] - val spanFg = span.getForegroundColor() - val spanBg = span.getBackgroundColor() + val spanStyle = span.toCustomStyle() - // If spans are touching/overlapping AND their colors match perfectly extend the span - if (spanStart <= currentEnd && spanFg == currentFg && spanBg == currentBg) { + // If spans are touching/overlapping AND their values match perfectly extend the span + if (spanStart <= currentEnd && spanStyle == currentStyle) { currentEnd = maxOf(currentEnd, spanEnd) } else { - // Colors changed or there is a gap. Commit the current merged block. - setCustomSpan(spannable, currentStart, currentEnd, currentFg, currentBg) + // Values changed or there is a gap. Commit the current merged block. + setCustomSpan(spannable, currentStart, currentEnd, currentStyle) // Start a new tracking block currentStart = spanStart currentEnd = spanEnd - currentFg = spanFg - currentBg = spanBg + currentStyle = spanStyle } } // Commit the final block - setCustomSpan(spannable, currentStart, currentEnd, currentFg, currentBg) + setCustomSpan(spannable, currentStart, currentEnd, currentStyle) } private fun setCustomSpan( spannable: Spannable, start: Int, end: Int, - fg: Int?, - bg: Int?, + style: CustomStyle, ) { - if (start >= end || (fg == null && bg == null)) return - - val span = EnrichedInputCustomStyleSpan(fg, bg) + if (start >= end || style.isEmpty()) return + + val span = + EnrichedInputCustomStyleSpan( + style.foregroundColor, + style.backgroundColor, + style.fontSize, + style.fontFamily, + view.context.assets, + view.allowFontScaling, + ) spannable.setSpan( span, start, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt index d0e1554a7..0bbe1ab09 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt @@ -149,6 +149,8 @@ class EnrichedSelection( var foundFg: Int? = null var foundBg: Int? = null + var foundFontSize: Float? = null + var foundFontFamily: String? = null for (span in spans) { val spanStart = spannable.getSpanStart(span) @@ -159,11 +161,13 @@ class EnrichedSelection( } else if (start >= spanStart && end <= spanEnd) { foundFg = span.getForegroundColor() foundBg = span.getBackgroundColor() + foundFontSize = span.getFontSize() + foundFontFamily = span.getFontFamily() break } } - state.setCustomStyle(foundFg, foundBg) + state.setCustomStyle(foundFg, foundBg, foundFontSize, foundFontFamily) } fun getParagraphSelection(): Pair { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt index c58832b93..1d7fb4a12 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt @@ -162,8 +162,10 @@ class EnrichedSpanState( fun setCustomStyle( fgColor: Int?, bgColor: Int?, + fontSize: Float? = null, + fontFamily: String? = null, ) { - this.customStyle = CustomStyle(fgColor, bgColor) + this.customStyle = CustomStyle(fgColor, bgColor, fontSize, fontFamily) emitStateChangeEvent() } @@ -341,6 +343,9 @@ class EnrichedSpanState( customStyleMap.putString("backgroundColor", "") } + customStyleMap.putDouble("fontSize", (customStyle?.fontSize ?: 0f).toDouble()) + customStyleMap.putString("fontFamily", customStyle?.fontFamily ?: "") + return customStyleMap }