diff --git a/.maestro/enrichedInput/flows/custom_style_fonts_visual.yaml b/.maestro/enrichedInput/flows/custom_style_fonts_visual.yaml new file mode 100644 index 000000000..ab8dacbf7 --- /dev/null +++ b/.maestro/enrichedInput/flows/custom_style_fonts_visual.yaml @@ -0,0 +1,116 @@ +appId: swmansion.enriched.example +--- +# Validates custom font size and font family on plain text, inline styles, and paragraph styles. +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'editor-input' + +# Section 1: Plain text with custom fonts + +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-24' +- inputText: '24px plain' +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-Nunito-Bold' +- inputText: 'Bold family' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-32' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-Courier' +- inputText: '32 Courier' +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-clear' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-clear' +- inputText: ' ' + +# Section 2: Inline styles + custom fonts + +- tapOn: + id: 'toolbar-bold' +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-20' +- inputText: 'Bold 20px' +- tapOn: + id: 'toolbar-bold' +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-italic' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-Nunito-Italic' +- inputText: 'Italic family' +- tapOn: + id: 'toolbar-italic' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-inline-code' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-CascadiaCode-Bold' +- inputText: 'Code bold' +- tapOn: + id: 'toolbar-inline-code' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-clear' +- inputText: ' ' +- pressKey: Enter + +# Section 3: Paragraph styles + custom fonts + +- tapOn: + id: 'toolbar-quote' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-CascadiaCode-Regular' +- inputText: 'Quote code family' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_style_fonts' diff --git a/.maestro/enrichedInput/screenshots/ios/custom_style_fonts.png b/.maestro/enrichedInput/screenshots/ios/custom_style_fonts.png new file mode 100644 index 000000000..26a41abb7 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/custom_style_fonts.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png index 7085daf9e..650394951 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png index 9859c8102..a50059814 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png index b2f8fc378..dd2d16428 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedText/flows/custom_style_fonts_visual.yaml b/.maestro/enrichedText/flows/custom_style_fonts_visual.yaml new file mode 100644 index 000000000..e5c9b62a4 --- /dev/null +++ b/.maestro/enrichedText/flows/custom_style_fonts_visual.yaml @@ -0,0 +1,29 @@ +appId: swmansion.enriched.example +--- +# Validates that custom style fonts are displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +

Regular family

+

24px plain

+

Bold family

+

32 Courier

+

Bold 20px Italic family Code bold

+
H5 40px
+

Quote code family

+ + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_style_fonts_visual' diff --git a/.maestro/enrichedText/screenshots/ios/custom_style_fonts_visual.png b/.maestro/enrichedText/screenshots/ios/custom_style_fonts_visual.png new file mode 100644 index 000000000..03f364cc3 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/custom_style_fonts_visual.png differ diff --git a/apps/example/src/components/FontFamilyPickerRow.tsx b/apps/example/src/components/FontFamilyPickerRow.tsx new file mode 100644 index 000000000..f4f907748 --- /dev/null +++ b/apps/example/src/components/FontFamilyPickerRow.tsx @@ -0,0 +1,100 @@ +import { type FC } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text } from 'react-native'; + +export interface FontFamilyOption { + label: string; + value: string; +} + +interface Props { + families: FontFamilyOption[]; + activeFamily: string; + onSelectFamily: (family: string) => void; + onClear: () => void; +} + +export const FontFamilyPickerRow: FC = ({ + families, + activeFamily, + onSelectFamily, + onClear, +}) => { + return ( + + + + + {families.map(({ label, value }) => { + const isActive = value === activeFamily; + return ( + onSelectFamily(value)} + style={[styles.familyButton, isActive && styles.familyButtonActive]} + > + + {label} + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + backgroundColor: 'rgba(0, 26, 114, 0.9)', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 8, + gap: 8, + }, + clearButton: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + clearText: { + color: 'white', + fontSize: 14, + lineHeight: 16, + }, + familyButton: { + minWidth: 36, + height: 28, + paddingHorizontal: 8, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + familyButtonActive: { + backgroundColor: 'rgb(0, 26, 114)', + borderWidth: 2, + borderColor: 'white', + }, + familyText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + lineHeight: 16, + }, +}); diff --git a/apps/example/src/components/FontSizePickerRow.tsx b/apps/example/src/components/FontSizePickerRow.tsx new file mode 100644 index 000000000..c22896909 --- /dev/null +++ b/apps/example/src/components/FontSizePickerRow.tsx @@ -0,0 +1,93 @@ +import { type FC } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text } from 'react-native'; + +interface Props { + sizes: number[]; + activeSize: number; + onSelectSize: (size: number) => void; + onClear: () => void; +} + +export const FontSizePickerRow: FC = ({ + sizes, + activeSize, + onSelectSize, + onClear, +}) => { + return ( + + + + + {sizes.map((size) => { + const isActive = size === activeSize; + return ( + onSelectSize(size)} + style={[styles.sizeButton, isActive && styles.sizeButtonActive]} + > + {size} + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + backgroundColor: 'rgba(0, 26, 114, 0.9)', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 8, + gap: 8, + }, + clearButton: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + clearText: { + color: 'white', + fontSize: 14, + lineHeight: 16, + }, + sizeButton: { + minWidth: 36, + height: 28, + paddingHorizontal: 8, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + sizeButtonActive: { + backgroundColor: 'rgb(0, 26, 114)', + borderWidth: 2, + borderColor: 'white', + }, + sizeText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + lineHeight: 16, + }, +}); diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index c44d4d5d8..4d997779b 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -9,6 +9,8 @@ import { } from 'react-native'; import { ToolbarButton } from './ToolbarButton'; import { ColorPickerRow } from './ColorPickerRow'; +import { FontSizePickerRow } from './FontSizePickerRow'; +import { FontFamilyPickerRow } from './FontFamilyPickerRow'; import type { OnChangeStateEvent, EnrichedTextInputInstance, @@ -36,6 +38,16 @@ const COLORS = [ '#ADD8E6', ]; +const FONT_SIZES = [12, 16, 20, 24, 28, 32, 36, 40]; + +const FONT_FAMILIES = [ + { label: 'Bold', value: 'Nunito-Bold' }, + { label: 'Italic', value: 'Nunito-Italic' }, + { label: 'Code', value: 'CascadiaCode-Regular' }, + { label: 'Code Bold', value: 'CascadiaCode-Bold' }, + { label: 'Courier', value: 'Courier' }, +] as const; + const STYLE_ITEMS = [ { name: 'bold', @@ -133,11 +145,24 @@ const STYLE_ITEMS = [ name: 'bg-color', text: 'BG', }, + { + name: 'font-size', + text: 'Aa', + }, + { + name: 'font-family', + text: 'Ff', + }, ] as const; type Item = (typeof STYLE_ITEMS)[number]; type StylesState = OnChangeStateEvent; -type OpenPicker = 'text-color' | 'bg-color' | null; +type OpenPicker = + | 'text-color' + | 'bg-color' + | 'font-size' + | 'font-family' + | null; export interface ToolbarProps { stylesState: StylesState; @@ -158,6 +183,11 @@ export const Toolbar: FC = ({ const activeFgColor = stylesState.customStyle?.foregroundColor ?? ''; const activeBgColor = stylesState.customStyle?.backgroundColor ?? ''; + const activeFontSize = stylesState.customStyle?.fontSize ?? 0; + const activeFontFamily = stylesState.customStyle?.fontFamily ?? ''; + const activeFontFamilyLabel = + FONT_FAMILIES.find((family) => family.value === activeFontFamily)?.label ?? + ''; const fgIndicatorColor = activeFgColor.length > 0 ? activeFgColor : 'transparent'; @@ -395,6 +425,48 @@ export const Toolbar: FC = ({ ); } + if (item.name === 'font-size') { + const fontSizeLabel = activeFontSize > 0 ? String(activeFontSize) : 'Aa'; + return ( + + setOpenPicker((prev) => (prev === 'font-size' ? null : 'font-size')) + } + style={[ + styles.colorButton, + layout === 'grid' ? styles.gridItem : undefined, + (openPicker === 'font-size' || activeFontSize > 0) && + styles.colorButtonActive, + ]} + > + {fontSizeLabel} + + ); + } + + if (item.name === 'font-family') { + const fontFamilyLabel = activeFontFamilyLabel || 'Ff'; + return ( + + setOpenPicker((prev) => + prev === 'font-family' ? null : 'font-family' + ) + } + style={[ + styles.colorButton, + layout === 'grid' ? styles.gridItem : undefined, + (openPicker === 'font-family' || activeFontFamily.length > 0) && + styles.colorButtonActive, + ]} + > + {fontFamilyLabel} + + ); + } + return ( = ({ setOpenPicker(null); }; + const handleSelectFontSize = (size: number) => { + editorRef?.current?.setStyle({ fontSize: size }); + setOpenPicker(null); + }; + + const handleClearFontSize = () => { + editorRef?.current?.setStyle({ fontSize: null }); + setOpenPicker(null); + }; + + const handleSelectFontFamily = (family: string) => { + editorRef?.current?.setStyle({ fontFamily: family }); + setOpenPicker(null); + }; + + const handleClearFontFamily = () => { + editorRef?.current?.setStyle({ fontFamily: null }); + setOpenPicker(null); + }; + return ( = ({ onClear={handleClearBgColor} /> )} + {openPicker === 'font-size' && ( + + )} + {openPicker === 'font-family' && ( + + )} ); }; diff --git a/apps/example/src/constants/editorConfig.ts b/apps/example/src/constants/editorConfig.ts index 092911f9e..f29ac11c0 100644 --- a/apps/example/src/constants/editorConfig.ts +++ b/apps/example/src/constants/editorConfig.ts @@ -36,6 +36,8 @@ export const DEFAULT_STYLES: StylesState = { customStyle: { foregroundColor: '', backgroundColor: '', + fontSize: 0, + fontFamily: '', }, }; diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 5e6123cd5..f78ce6b0c 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1168,7 +1168,12 @@ - (void)tryUpdatingActiveStyles { ?: "", .backgroundColor = [[currentCustomStyle.backgroundColor hexString] UTF8String] - ?: ""}}); + ?: "", + .fontSize = currentCustomStyle.fontSize + ? [currentCustomStyle.fontSize doubleValue] + : 0.0, + .fontFamily = + [currentCustomStyle.fontFamily UTF8String] ?: ""}}); } } @@ -1568,6 +1573,19 @@ - (void)setStyle:(NSString *)styleJSON { : [RCTConvert UIColor:bgRaw]; } + id fsRaw = dict[@"fontSize"]; + if (fsRaw != nil) { + processedDict[@"fontSize"] = + [fsRaw isKindOfClass:[NSNull class]] ? [NSNull null] : fsRaw; + } + + id ffRaw = dict[@"fontFamily"]; + if (ffRaw != nil) { + processedDict[@"fontFamily"] = [ffRaw isKindOfClass:[NSNull class]] + ? [NSNull null] + : [RCTConvert NSString:ffRaw]; + } + [customStyleClass applyStyleFromDict:processedDict selectedRange:selectedRange]; [self anyTextMayHaveBeenModified]; @@ -1976,7 +1994,12 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText { .backgroundColor = [[contextCustomStyleData.backgroundColor hexString] UTF8String] - ?: ""}}}); + ?: "", + .fontSize = contextCustomStyleData.fontSize + ? [contextCustomStyleData.fontSize doubleValue] + : 0.0, + .fontFamily = + [contextCustomStyleData.fontFamily UTF8String] ?: ""}}}); } } diff --git a/ios/config/EnrichedConfig.h b/ios/config/EnrichedConfig.h index d39248baf..e72f58f6b 100644 --- a/ios/config/EnrichedConfig.h +++ b/ios/config/EnrichedConfig.h @@ -89,6 +89,7 @@ - (CGFloat)codeBlockBorderRadius; - (void)setCodeBlockBorderRadius:(CGFloat)newValue; - (void)invalidateFonts; +- (CGFloat)scaledFontSizeForValue:(CGFloat)fontSize; - (NSNumber *)scaledPrimaryFontSize; - (BOOL)allowFontScaling; - (void)setAllowFontScaling:(BOOL)newValue; diff --git a/ios/config/EnrichedConfig.mm b/ios/config/EnrichedConfig.mm index 05d0e675e..6a8f146ae 100644 --- a/ios/config/EnrichedConfig.mm +++ b/ios/config/EnrichedConfig.mm @@ -162,11 +162,7 @@ - (void)setPrimaryLineHeight:(CGFloat)newValue { } - (CGFloat)scaledPrimaryLineHeight { - if (!_allowFontScaling) { - return [self primaryLineHeight]; - } - return [[UIFontMetrics defaultMetrics] - scaledValueForValue:[self primaryLineHeight]]; + return [self scaledFontSizeForValue:[self primaryLineHeight]]; } - (NSString *)primaryFontWeight { @@ -235,10 +231,7 @@ - (void)setMentionIndicators:(NSSet *)newValue { } - (CGFloat)h1FontSize { - if (!_allowFontScaling) { - return _h1FontSize; - } - return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h1FontSize]; + return [self scaledFontSizeForValue:_h1FontSize]; } - (void)setH1FontSize:(CGFloat)newValue { @@ -254,10 +247,7 @@ - (void)setH1Bold:(BOOL)newValue { } - (CGFloat)h2FontSize { - if (!_allowFontScaling) { - return _h2FontSize; - } - return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h2FontSize]; + return [self scaledFontSizeForValue:_h2FontSize]; } - (void)setH2FontSize:(CGFloat)newValue { @@ -273,10 +263,7 @@ - (void)setH2Bold:(BOOL)newValue { } - (CGFloat)h3FontSize { - if (!_allowFontScaling) { - return _h3FontSize; - } - return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h3FontSize]; + return [self scaledFontSizeForValue:_h3FontSize]; } - (void)setH3FontSize:(CGFloat)newValue { @@ -292,10 +279,7 @@ - (void)setH3Bold:(BOOL)newValue { } - (CGFloat)h4FontSize { - if (!_allowFontScaling) { - return _h4FontSize; - } - return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h4FontSize]; + return [self scaledFontSizeForValue:_h4FontSize]; } - (void)setH4FontSize:(CGFloat)newValue { @@ -311,10 +295,7 @@ - (void)setH4Bold:(BOOL)newValue { } - (CGFloat)h5FontSize { - if (!_allowFontScaling) { - return _h5FontSize; - } - return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h5FontSize]; + return [self scaledFontSizeForValue:_h5FontSize]; } - (void)setH5FontSize:(CGFloat)newValue { @@ -330,10 +311,7 @@ - (void)setH5Bold:(BOOL)newValue { } - (CGFloat)h6FontSize { - if (!_allowFontScaling) { - return _h6FontSize; - } - return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h6FontSize]; + return [self scaledFontSizeForValue:_h6FontSize]; } - (void)setH6FontSize:(CGFloat)newValue { @@ -560,13 +538,15 @@ - (void)setAllowFontScaling:(BOOL)newValue { } } -- (NSNumber *)scaledPrimaryFontSize { +- (CGFloat)scaledFontSizeForValue:(CGFloat)fontSize { if (!_allowFontScaling) { - return [self primaryFontSize]; + return fontSize; } - CGFloat scaledSize = [[UIFontMetrics defaultMetrics] - scaledValueForValue:[[self primaryFontSize] floatValue]]; - return @(scaledSize); + return [[UIFontMetrics defaultMetrics] scaledValueForValue:fontSize]; +} + +- (NSNumber *)scaledPrimaryFontSize { + return @([self scaledFontSizeForValue:[[self primaryFontSize] floatValue]]); } - (CGFloat)checkboxListBoxSize { diff --git a/ios/customStyleData/CustomStyleData.h b/ios/customStyleData/CustomStyleData.h index 7ee405f8e..c6ac30e40 100644 --- a/ios/customStyleData/CustomStyleData.h +++ b/ios/customStyleData/CustomStyleData.h @@ -5,6 +5,8 @@ @property(nonatomic, strong, nullable) UIColor *foregroundColor; @property(nonatomic, strong, nullable) UIColor *backgroundColor; +@property(nonatomic, strong, nullable) NSNumber *fontSize; +@property(nonatomic, copy, nullable) NSString *fontFamily; - (BOOL)isEmpty; diff --git a/ios/customStyleData/CustomStyleData.mm b/ios/customStyleData/CustomStyleData.mm index c08f1fd54..cfdd0dc81 100644 --- a/ios/customStyleData/CustomStyleData.mm +++ b/ios/customStyleData/CustomStyleData.mm @@ -3,7 +3,8 @@ @implementation CustomStyleData - (BOOL)isEmpty { - return _foregroundColor == nil && _backgroundColor == nil; + return _foregroundColor == nil && _backgroundColor == nil && + _fontSize == nil && _fontFamily == nil; } - (void)mergeFromDict:(NSDictionary *)dict { @@ -17,6 +18,20 @@ - (void)mergeFromDict:(NSDictionary *)dict { self.backgroundColor = [bgVal isKindOfClass:[UIColor class]] ? (UIColor *)bgVal : nil; } + id fsVal = dict[@"fontSize"]; + if (fsVal != nil) { + if ([fsVal isKindOfClass:[NSNumber class]] && + [(NSNumber *)fsVal doubleValue] > 0) { + self.fontSize = (NSNumber *)fsVal; + } else { + self.fontSize = nil; + } + } + id ffVal = dict[@"fontFamily"]; + if (ffVal != nil) { + self.fontFamily = + [ffVal isKindOfClass:[NSString class]] ? (NSString *)ffVal : nil; + } } - (BOOL)isEqual:(id)object { @@ -29,17 +44,25 @@ - (BOOL)isEqual:(id)object { [_foregroundColor isEqual:other.foregroundColor]; BOOL bgEqual = (_backgroundColor == other.backgroundColor) || [_backgroundColor isEqual:other.backgroundColor]; - return fgEqual && bgEqual; + BOOL fsEqual = (_fontSize == other.fontSize) || + (_fontSize != nil && other.fontSize != nil && + [_fontSize isEqualToNumber:other.fontSize]); + BOOL ffEqual = (_fontFamily == other.fontFamily) || + [_fontFamily isEqualToString:other.fontFamily]; + return fgEqual && bgEqual && fsEqual && ffEqual; } - (NSUInteger)hash { - return [_foregroundColor hash] ^ [_backgroundColor hash]; + return [_foregroundColor hash] ^ [_backgroundColor hash] ^ [_fontSize hash] ^ + [_fontFamily hash]; } - (id)copyWithZone:(NSZone *)zone { CustomStyleData *copy = [[CustomStyleData allocWithZone:zone] init]; copy.foregroundColor = self.foregroundColor; copy.backgroundColor = self.backgroundColor; + copy.fontSize = self.fontSize; + copy.fontFamily = self.fontFamily; return copy; } diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index c6b778a73..db0847e52 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -1495,6 +1495,22 @@ + (NSString *)tagContentForStyle:(NSNumber *)style [cssProps appendString:@" "]; [cssProps appendFormat:@"background-color: %@;", bg]; } + if (data.fontSize != nil) { + if (cssProps.length > 0) + [cssProps appendString:@" "]; + [cssProps + appendFormat:@"font-size: %gpx;", [data.fontSize doubleValue]]; + } + if (data.fontFamily.length > 0) { + if (cssProps.length > 0) + [cssProps appendString:@" "]; + if ([data.fontFamily rangeOfString:@" "].location != NSNotFound) { + // font family contains spaces, wrap in single quotes + [cssProps appendFormat:@"font-family: '%@';", data.fontFamily]; + } else { + [cssProps appendFormat:@"font-family: %@;", data.fontFamily]; + } + } if (cssProps.length > 0) { return [NSString stringWithFormat:@"span style=\"%@\"", cssProps]; } @@ -1533,6 +1549,8 @@ + (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: static NSRegularExpression *styleAttrRegex; static NSRegularExpression *fgRegex; static NSRegularExpression *bgRegex; + static NSRegularExpression *fontSizeRegex; + static NSRegularExpression *fontFamilyRegex; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ styleAttrRegex = [NSRegularExpression @@ -1551,6 +1569,17 @@ + (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: regularExpressionWithPattern:@"background-color\\s*:\\s*([^;]+)" options:NSRegularExpressionCaseInsensitive error:nil]; + + fontSizeRegex = [NSRegularExpression + regularExpressionWithPattern: + @"font-size\\s*:\\s*([0-9.]+)(?:\\s*px)?(?=\\s*;|\\s*$)" + options:NSRegularExpressionCaseInsensitive + error:nil]; + + fontFamilyRegex = [NSRegularExpression + regularExpressionWithPattern:@"font-family\\s*:\\s*([^;]+)" + options:NSRegularExpressionCaseInsensitive + error:nil]; }); NSTextCheckingResult *attrMatch = @@ -1581,6 +1610,50 @@ + (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: colorFromCSSString:[css substringWithRange:[bgMatch rangeAtIndex:1]]]; } + NSTextCheckingResult *fontSizeMatch = + [fontSizeRegex firstMatchInString:css + options:0 + range:NSMakeRange(0, css.length)]; + if (fontSizeMatch) { + NSString *fontSizeString = + [css substringWithRange:[fontSizeMatch rangeAtIndex:1]]; + double parsedFontSize = [fontSizeString doubleValue]; + if (parsedFontSize > 0) { + data.fontSize = @(parsedFontSize); + } + } + + NSTextCheckingResult *fontFamilyMatch = + [fontFamilyRegex firstMatchInString:css + options:0 + range:NSMakeRange(0, css.length)]; + if (fontFamilyMatch) { + NSString *fontFamilyString = [[css + substringWithRange:[fontFamilyMatch rangeAtIndex:1]] + stringByTrimmingCharactersInSet:[NSCharacterSet + whitespaceAndNewlineCharacterSet]]; + + // If font family contains a comma, use the first part e.g. "Nunito, + // sans-serif" -> "Nunito" + NSRange commaRange = [fontFamilyString rangeOfString:@","]; + if (commaRange.location != NSNotFound) { + fontFamilyString = + [[fontFamilyString substringToIndex:commaRange.location] + stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + } + if (([fontFamilyString hasPrefix:@"'"] && + [fontFamilyString hasSuffix:@"'"]) || + ([fontFamilyString hasPrefix:@"\""] && + [fontFamilyString hasSuffix:@"\""])) { + fontFamilyString = [fontFamilyString + substringWithRange:NSMakeRange(1, fontFamilyString.length - 2)]; + } + if (fontFamilyString.length > 0) { + data.fontFamily = fontFamilyString; + } + } + return data.isEmpty ? nil : data; } diff --git a/ios/styles/CustomStyle.mm b/ios/styles/CustomStyle.mm index b79f767db..14f186837 100644 --- a/ios/styles/CustomStyle.mm +++ b/ios/styles/CustomStyle.mm @@ -1,8 +1,10 @@ #import "ColorExtension.h" #import "CustomStyleData.h" #import "EnrichedTextInputView.h" +#import "FontExtension.h" #import "RangeUtils.h" #import "StyleHeaders.h" +#import static NSString *const CustomStyleAttributeName = @"EnrichedCustomStyle"; @@ -24,6 +26,46 @@ - (NSInteger)stylePriority { return 1; } +- (UIFont *)resolveFont:(UIFont *)font forData:(CustomStyleData *)data { + UIFont *resolvedFont = font ?: [self.host.config primaryFont]; + UIFont *originalFont = resolvedFont; + + if (data.fontFamily.length > 0) { + resolvedFont = [RCTFont updateFont:resolvedFont + withFamily:data.fontFamily + size:0 + weight:nullptr + style:nullptr + variant:nullptr + scaleMultiplier:1]; + resolvedFont = [resolvedFont withFontTraits:originalFont]; + } + + if (data.fontSize != nil) { + CGFloat pointSize = + [self.host.config scaledFontSizeForValue:[data.fontSize doubleValue]]; + resolvedFont = [resolvedFont setSize:pointSize]; + } + + return resolvedFont; +} + +- (BOOL)appliesStylingToTyping { + CustomStyleData *data = + [self getCustomStyleDataAt:self.host.textView.selectedRange.location]; + return data.fontSize != nil || data.fontFamily.length > 0; +} + +- (void)applyStylingToTypingAttrs:(NSMutableDictionary *)attributes { + CustomStyleData *data = + [self getCustomStyleDataAt:self.host.textView.selectedRange.location]; + if (data.fontSize == nil && data.fontFamily.length == 0) + return; + + UIFont *currentFont = attributes[NSFontAttributeName]; + attributes[NSFontAttributeName] = [self resolveFont:currentFont forData:data]; +} + - (BOOL)styleCondition:(id)value range:(NSRange)range { if (![value isKindOfClass:[CustomStyleData class]]) return NO; @@ -65,17 +107,37 @@ - (void)applyStyling:(NSRange)range { attrs[NSBackgroundColorAttributeName] = [data.backgroundColor colorWithResolvedAlpha]; } - if (attrs.count == 0) - return; + + BOOL hasFontOverride = + data.fontSize != nil || data.fontFamily.length > 0; // Skip newline characters so background color doesn't bleed. NSArray *nonNewlineRanges = [RangeUtils getNonNewlineRangesIn:self.host.textView range:subRange]; for (NSValue *rangeVal in nonNewlineRanges) { - [self.host.textView.textStorage - addAttributes:attrs - range:[rangeVal rangeValue]]; + NSRange applyRange = [rangeVal rangeValue]; + if (attrs.count > 0) { + [self.host.textView.textStorage addAttributes:attrs + range:applyRange]; + } + if (hasFontOverride) { + [self.host.textView.textStorage + enumerateAttribute:NSFontAttributeName + inRange:applyRange + options:0 + usingBlock:^(id _Nullable value, + NSRange fontRange, + BOOL *_Nonnull stop) { + UIFont *font = (UIFont *)value; + UIFont *newFont = [self resolveFont:font + forData:data]; + [self.host.textView.textStorage + addAttribute:NSFontAttributeName + value:newFont + range:fontRange]; + }]; + } } }]; } diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index 0b102b700..9f3374dc4 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -295,10 +295,14 @@ export const EnrichedTextInput = ({ setStyle: (customStyle: { foregroundColor?: ColorValue | null; backgroundColor?: ColorValue | null; + fontSize?: number | null; + fontFamily?: string | null; }) => { const payload: { foregroundColor?: number | null; backgroundColor?: number | null; + fontSize?: number | null; + fontFamily?: string | null; } = {}; if (customStyle.foregroundColor !== undefined) { payload.foregroundColor = getSafeColorInt(customStyle.foregroundColor); @@ -306,6 +310,12 @@ export const EnrichedTextInput = ({ if (customStyle.backgroundColor !== undefined) { payload.backgroundColor = getSafeColorInt(customStyle.backgroundColor); } + if (customStyle.fontSize !== undefined) { + payload.fontSize = customStyle.fontSize; + } + if (customStyle.fontFamily !== undefined) { + payload.fontFamily = customStyle.fontFamily; + } Commands.setStyle(nullthrows(nativeRef.current), JSON.stringify(payload)); }, })); diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 3467acadb..eea2099d1 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -127,6 +127,8 @@ export interface OnChangeStateEvent { customStyle: { foregroundColor: string; backgroundColor: string; + fontSize: Float; + fontFamily: string; }; } @@ -287,6 +289,8 @@ export interface OnContextMenuItemPressEvent { customStyle: { foregroundColor: string; backgroundColor: string; + fontSize: Float; + fontFamily: string; }; }; } diff --git a/src/types.ts b/src/types.ts index 80bf14ba0..3454d70d5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -383,6 +383,8 @@ export interface OnChangeStateEvent { customStyle: { foregroundColor: string; backgroundColor: string; + fontSize: number; + fontFamily: string; }; } @@ -579,6 +581,8 @@ export interface EnrichedTextInputInstance extends NativeMethods { setStyle: (customStyle: { foregroundColor?: ColorValue | null; backgroundColor?: ColorValue | null; + fontSize?: number | null; + fontFamily?: string | null; }) => void; } diff --git a/src/web/useOnChangeState.ts b/src/web/useOnChangeState.ts index 3affc3536..2015f86e2 100644 --- a/src/web/useOnChangeState.ts +++ b/src/web/useOnChangeState.ts @@ -104,6 +104,8 @@ function buildState( editor.getAttributes('customStyle').foregroundColor ?? '', backgroundColor: editor.getAttributes('customStyle').backgroundColor ?? '', + fontSize: 0, + fontFamily: '', }, }; }