diff --git a/.maestro/enrichedInput/flows/custom_style_colors_visual.yaml b/.maestro/enrichedInput/flows/custom_style_colors_visual.yaml
new file mode 100644
index 00000000..04ad337d
--- /dev/null
+++ b/.maestro/enrichedInput/flows/custom_style_colors_visual.yaml
@@ -0,0 +1,118 @@
+appId: swmansion.enriched.example
+---
+# Validates custom colors on plain text, inline styles, and paragraph styles.
+- launchApp
+
+- tapOn:
+ id: 'toggle-screen-button'
+
+- tapOn:
+ id: 'editor-input'
+
+# Section 1: Plain text with colors
+
+- tapOn:
+ id: 'toolbar-text-color'
+- tapOn:
+ id: 'color-swatch-FF0000'
+- inputText: 'Red text'
+- tapOn:
+ id: 'toolbar-text-color'
+- tapOn:
+ id: 'color-swatch-clear'
+- inputText: ' '
+
+- tapOn:
+ id: 'toolbar-bg-color'
+- tapOn:
+ id: 'color-swatch-FFFF00'
+- inputText: 'Yellow back'
+- tapOn:
+ id: 'toolbar-bg-color'
+- tapOn:
+ id: 'color-swatch-clear'
+- inputText: ' '
+
+- tapOn:
+ id: 'toolbar-text-color'
+- tapOn:
+ id: 'color-swatch-FF0000'
+- tapOn:
+ id: 'toolbar-bg-color'
+- tapOn:
+ id: 'color-swatch-FFFF00'
+- inputText: 'Red+Yellow'
+- tapOn:
+ id: 'toolbar-text-color'
+- tapOn:
+ id: 'color-swatch-clear'
+- tapOn:
+ id: 'toolbar-bg-color'
+- tapOn:
+ id: 'color-swatch-clear'
+- inputText: ' '
+
+# Section 2: Inline styles + color
+
+- tapOn:
+ id: 'toolbar-bold'
+- tapOn:
+ id: 'toolbar-text-color'
+- tapOn:
+ id: 'color-swatch-FF0000'
+- inputText: 'Bold red'
+- tapOn:
+ id: 'toolbar-bold'
+- tapOn:
+ id: 'toolbar-text-color'
+- tapOn:
+ id: 'color-swatch-clear'
+- inputText: ' '
+
+- tapOn:
+ id: 'toolbar-italic'
+- tapOn:
+ id: 'toolbar-bg-color'
+- tapOn:
+ id: 'color-swatch-FFFF00'
+- inputText: 'Italic yellow back'
+- tapOn:
+ id: 'toolbar-italic'
+- tapOn:
+ id: 'toolbar-bg-color'
+- tapOn:
+ id: 'color-swatch-clear'
+- inputText: ' '
+- pressKey: Enter
+
+# Section 3: Paragraph styles + color
+
+- tapOn:
+ id: 'toolbar-heading-5'
+- tapOn:
+ id: 'toolbar-text-color'
+- tapOn:
+ id: 'color-swatch-FF0000'
+- inputText: 'H5 red'
+- pressKey: Enter
+- tapOn:
+ id: 'toolbar-text-color'
+- tapOn:
+ id: 'color-swatch-clear'
+
+- tapOn:
+ id: 'toolbar-quote'
+- tapOn:
+ id: 'toolbar-bg-color'
+- tapOn:
+ id: 'color-swatch-FFFF00'
+- inputText: 'Quote yellow back'
+- tapOn:
+ id: 'toolbar-bg-color'
+- tapOn:
+ id: 'color-swatch-clear'
+
+- runFlow:
+ file: '../subflows/capture_or_assert_screenshot.yaml'
+ env:
+ SCREENSHOT_NAME: 'custom_style_colors'
diff --git a/.maestro/enrichedInput/screenshots/ios/custom_style_colors.png b/.maestro/enrichedInput/screenshots/ios/custom_style_colors.png
new file mode 100644
index 00000000..e8e39c71
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/custom_style_colors.png differ
diff --git a/.maestro/enrichedText/flows/custom_style_colors_visual.yaml b/.maestro/enrichedText/flows/custom_style_colors_visual.yaml
new file mode 100644
index 00000000..e304b5b9
--- /dev/null
+++ b/.maestro/enrichedText/flows/custom_style_colors_visual.yaml
@@ -0,0 +1,28 @@
+appId: swmansion.enriched.example
+---
+# Validates that custom style colors 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: >
+
+
Standard 6-digit Hex text
+ White text on black background
+ 25% transparent green background
+ 50% transparent blue text
+ Red 3-digit shorthand text
+ Black text on green
+
+
+- runFlow:
+ file: '../subflows/capture_or_assert_screenshot.yaml'
+ env:
+ SCREENSHOT_NAME: 'custom_style_colors_visual'
diff --git a/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png b/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png
new file mode 100644
index 00000000..fc417d9b
Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png differ
diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt
index 9dcbb824..3cb4a5e8 100644
--- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt
+++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt
@@ -479,6 +479,13 @@ class EnrichedTextInputViewManager :
view?.setTextAlignment(alignment)
}
+ override fun setStyle(
+ view: EnrichedTextInputView?,
+ styleJSON: String,
+ ) {
+ // TODO: Implement
+ }
+
override fun measure(
context: Context,
localData: ReadableMap?,
diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock
index 0a2926cc..b18f8dd6 100644
--- a/apps/example/ios/Podfile.lock
+++ b/apps/example/ios/Podfile.lock
@@ -2026,7 +2026,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b
- hermes-engine: 484c595d9e6a0b7b7607e8ead508ba5c472493c7
+ hermes-engine: b4dad6ba67535bb03c8ff1006b337cba14db16cb
RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257
RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64
RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a
@@ -2035,7 +2035,7 @@ SPEC CHECKSUMS:
React: 1b1536b9099195944034e65b1830f463caaa8390
React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5
React-Core: 39ee05b5798296f433dd3c3624c57a187c1510e3
- React-Core-prebuilt: 3ca7a49d919f940e7de8fb0c2a3f5cfcb665f09b
+ React-Core-prebuilt: 69556f895326f23c007f3a6869340045d7dca106
React-CoreModules: e78bfd2617075bc0e50c689df4a29232bd72ad82
React-cxxreact: 3fe21801d46097cf74c3dff6953677bebc4a3c2a
React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74
@@ -2097,8 +2097,8 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 706b65371b90b5cc797b6639e8979f2e5cecd6da
ReactCodegen: ab01ebfffac5cda9140204eb872ed97c15df225f
ReactCommon: 47ef95b0920948a0b54d7439f7452501eeeac071
- ReactNativeDependencies: 652705a9bc92800d0b1e15177a61ba70d89d24dd
- ReactNativeEnrichedHtml: 93722241410f2daaa8c20ce6bcfcf4666bfd9166
+ ReactNativeDependencies: 8a208df374583424130645685d86306befc275cf
+ ReactNativeEnrichedHtml: 7d90df4aced7f533c7bd15ac296879b214413361
Yoga: e83c3121d079541e69f3c5c623faaaf933fb5812
PODFILE CHECKSUM: 88c10840d02e9884b2dc3f457d5120f83ac3803b
diff --git a/apps/example/src/components/ColorPickerRow.tsx b/apps/example/src/components/ColorPickerRow.tsx
new file mode 100644
index 00000000..ad428cb1
--- /dev/null
+++ b/apps/example/src/components/ColorPickerRow.tsx
@@ -0,0 +1,92 @@
+import { type FC } from 'react';
+import { Pressable, ScrollView, StyleSheet, Text } from 'react-native';
+
+interface Props {
+ colors: string[];
+ activeColor: string;
+ onSelectColor: (color: string) => void;
+ onClear: () => void;
+}
+
+export const ColorPickerRow: FC = ({
+ colors,
+ activeColor,
+ onSelectColor,
+ onClear,
+}) => {
+ return (
+
+
+ ✕
+
+ {colors.map((color) => {
+ const isActive = color.toLowerCase() === activeColor?.toLowerCase();
+ const swatchId = `color-swatch-${color.replace('#', '').toUpperCase()}`;
+ return (
+ onSelectColor(color)}
+ style={[
+ styles.swatch,
+ { backgroundColor: color },
+ isActive && styles.swatchActive,
+ color === '#FFFFFF' && styles.swatchBordered,
+ ]}
+ />
+ );
+ })}
+
+ );
+};
+
+const SWATCH_SIZE = 28;
+
+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: SWATCH_SIZE,
+ height: SWATCH_SIZE,
+ borderRadius: SWATCH_SIZE / 2,
+ backgroundColor: 'rgba(255,255,255,0.15)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ clearText: {
+ color: 'white',
+ fontSize: 14,
+ lineHeight: 16,
+ },
+ swatch: {
+ width: SWATCH_SIZE,
+ height: SWATCH_SIZE,
+ borderRadius: SWATCH_SIZE / 2,
+ },
+ swatchActive: {
+ borderWidth: 3,
+ borderColor: 'white',
+ },
+ swatchBordered: {
+ borderWidth: 1,
+ borderColor: 'rgba(0,0,0,0.25)',
+ },
+});
diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx
index 20109e39..c44d4d5d 100644
--- a/apps/example/src/components/Toolbar.tsx
+++ b/apps/example/src/components/Toolbar.tsx
@@ -1,5 +1,14 @@
-import { FlatList, type ListRenderItemInfo, StyleSheet } from 'react-native';
+import { useState } from 'react';
+import {
+ FlatList,
+ type ListRenderItemInfo,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
import { ToolbarButton } from './ToolbarButton';
+import { ColorPickerRow } from './ColorPickerRow';
import type {
OnChangeStateEvent,
EnrichedTextInputInstance,
@@ -8,6 +17,25 @@ import type { FC } from 'react';
const GRID_COLUMNS = 8;
+const COLORS = [
+ '#000000',
+ '#FFFFFF',
+ '#808080',
+ '#FF0000',
+ '#FF6600',
+ '#FFFF00',
+ '#00FF00',
+ '#008000',
+ '#00FFFF',
+ '#0000FF',
+ '#800080',
+ '#FF00FF',
+ '#FF69B4',
+ '#A52A2A',
+ '#FFA500',
+ '#ADD8E6',
+];
+
const STYLE_ITEMS = [
{
name: 'bold',
@@ -97,10 +125,19 @@ const STYLE_ITEMS = [
name: 'align-right',
icon: 'align-right',
},
+ {
+ name: 'text-color',
+ text: 'A',
+ },
+ {
+ name: 'bg-color',
+ text: 'BG',
+ },
] as const;
type Item = (typeof STYLE_ITEMS)[number];
type StylesState = OnChangeStateEvent;
+type OpenPicker = 'text-color' | 'bg-color' | null;
export interface ToolbarProps {
stylesState: StylesState;
@@ -117,6 +154,20 @@ export const Toolbar: FC = ({
onSelectImage,
layout = 'horizontal',
}) => {
+ const [openPicker, setOpenPicker] = useState(null);
+
+ const activeFgColor = stylesState.customStyle?.foregroundColor ?? '';
+ const activeBgColor = stylesState.customStyle?.backgroundColor ?? '';
+
+ const fgIndicatorColor =
+ activeFgColor.length > 0 ? activeFgColor : 'transparent';
+ const fgIndicatorBorder =
+ activeFgColor.length > 0 ? activeFgColor : 'rgba(255,255,255,0.4)';
+ const bgIndicatorColor =
+ activeBgColor.length > 0 ? activeBgColor : 'transparent';
+ const bgIndicatorBorder =
+ activeBgColor.length > 0 ? activeBgColor : 'rgba(255,255,255,0.4)';
+
const handlePress = (item: Item) => {
const currentRef = editorRef?.current;
if (!currentRef) return;
@@ -168,7 +219,6 @@ export const Toolbar: FC = ({
editorRef.current?.toggleOrderedList();
break;
case 'checkbox-list':
- // Make checkbox checked by default
editorRef.current?.toggleCheckboxList(true);
break;
case 'link':
@@ -289,6 +339,62 @@ export const Toolbar: FC = ({
};
const renderItem = ({ item }: ListRenderItemInfo- ) => {
+ if (item.name === 'text-color') {
+ return (
+
+ setOpenPicker((prev) =>
+ prev === 'text-color' ? null : 'text-color'
+ )
+ }
+ style={[
+ styles.colorButton,
+ layout === 'grid' ? styles.gridItem : undefined,
+ openPicker === 'text-color' && styles.colorButtonActive,
+ ]}
+ >
+ A
+
+
+ );
+ }
+
+ if (item.name === 'bg-color') {
+ return (
+
+ setOpenPicker((prev) => (prev === 'bg-color' ? null : 'bg-color'))
+ }
+ style={[
+ styles.colorButton,
+ layout === 'grid' ? styles.gridItem : undefined,
+ openPicker === 'bg-color' && styles.colorButtonActive,
+ ]}
+ >
+ BG
+
+
+ );
+ }
+
return (
= ({
const keyExtractor = (item: Item) => item.name;
+ const handleSelectFgColor = (color: string) => {
+ editorRef?.current?.setStyle({ foregroundColor: color });
+ setOpenPicker(null);
+ };
+
+ const handleClearFgColor = () => {
+ editorRef?.current?.setStyle({ foregroundColor: null });
+ setOpenPicker(null);
+ };
+
+ const handleSelectBgColor = (color: string) => {
+ editorRef?.current?.setStyle({ backgroundColor: color });
+ setOpenPicker(null);
+ };
+
+ const handleClearBgColor = () => {
+ editorRef?.current?.setStyle({ backgroundColor: null });
+ setOpenPicker(null);
+ };
+
return (
-
+
+
+ {openPicker === 'text-color' && (
+
+ )}
+ {openPicker === 'bg-color' && (
+
+ )}
+
);
};
const styles = StyleSheet.create({
- container: {
+ wrapper: {
+ width: '100%',
+ },
+ list: {
width: '100%',
},
gridItem: {
flexBasis: `${100 / GRID_COLUMNS}%`,
aspectRatio: 1,
},
+ colorButton: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ width: 56,
+ height: 56,
+ backgroundColor: 'rgba(0, 26, 114, 0.8)',
+ gap: 2,
+ },
+ colorButtonActive: {
+ backgroundColor: 'rgb(0, 26, 114)',
+ },
+ colorButtonLabel: {
+ color: 'white',
+ fontSize: 15,
+ fontWeight: '700',
+ lineHeight: 17,
+ },
+ colorIndicator: {
+ width: 20,
+ height: 5,
+ borderRadius: 2,
+ borderWidth: 1,
+ },
});
diff --git a/apps/example/src/constants/editorConfig.ts b/apps/example/src/constants/editorConfig.ts
index 2b9d44b0..092911f9 100644
--- a/apps/example/src/constants/editorConfig.ts
+++ b/apps/example/src/constants/editorConfig.ts
@@ -33,6 +33,10 @@ export const DEFAULT_STYLES: StylesState = {
mention: DEFAULT_STYLE_STATE,
checkboxList: DEFAULT_STYLE_STATE,
alignment: 'auto',
+ customStyle: {
+ foregroundColor: '',
+ backgroundColor: '',
+ },
};
export const DEFAULT_LINK_STATE = {
diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c
index c4ece832..af434a98 100644
--- a/cpp/parser/GumboNormalizer.c
+++ b/cpp/parser/GumboNormalizer.c
@@ -720,9 +720,47 @@ static void walk_node(GumboNode *node, buffer_t *out) {
const char *sval = get_attr(el, "style");
size_t slen = sval ? strlen(sval) : 0;
css_styles_t s = parse_css_style(sval, slen);
+
+ size_t fg_len = 0;
+ const char *fg = find_css_value(sval, slen, "color", &fg_len);
+
+ size_t bg_len = 0;
+ const char *bg = find_css_value(sval, slen, "background-color", &bg_len);
+
+ int has_fg = (fg && fg_len > 0);
+ int has_bg = (bg && bg_len > 0);
+
+ // emit the wrapper span if colors exist
+ if (has_fg || has_bg) {
+ buffer_append_str(out, "");
+ }
+
+ // handle inner formatting (, , etc.) and children
emit_styles_open(out, s);
walk_children(node, out);
emit_styles_close(out, s);
+
+ // close the wrapper span
+ if (has_fg || has_bg) {
+ buffer_append_str(out, "");
+ }
+
return;
}
diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp
index f45b7ec9..185c5525 100644
--- a/cpp/tests/GumboParserTest.cpp
+++ b/cpp/tests/GumboParserTest.cpp
@@ -253,6 +253,75 @@ TEST(GumboParserTest, SpanRemappings) {
"x"),
"
x");
+
+ // Foreground color only
+ EXPECT_EQ(
+ GumboParser::normalizeHtml("x"),
+ "x");
+ EXPECT_EQ(
+ GumboParser::normalizeHtml("x"),
+ "x");
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+
+ // Background color only
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+
+ // Both colors combined
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+
+ // Color + Single Formatter
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"
+ "span>"),
+ "x");
+
+ // Color + Multiple inline styles
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+
+ // Mix of colors and inline styles
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
}
TEST(GumboParserTest, EnrichedTagRemappings) {
diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm
index 21ff1b72..f8bb2f77 100644
--- a/ios/EnrichedTextInputView.mm
+++ b/ios/EnrichedTextInputView.mm
@@ -1,6 +1,7 @@
#import "EnrichedTextInputView.h"
#import "AlignmentUtils.h"
#import "AttachmentLayoutUtils.h"
+#import "ColorExtension.h"
#import "CoreText/CoreText.h"
#import "DotReplacementUtils.h"
#import "HtmlParser.h"
@@ -19,6 +20,7 @@
#import "WordsUtils.h"
#import "ZeroWidthSpaceUtils.h"
#import
+#import
#import
#import
#import
@@ -62,6 +64,7 @@ @implementation EnrichedTextInputView {
NSString *_submitBehavior;
NSDictionary *_capturedAttributesBeforeChange;
NSString *_recentlyEmittedAlignment;
+ CustomStyleData *_recentlyEmittedCustomStyle;
}
@synthesize blockEmitting = blockEmitting;
@@ -1120,6 +1123,15 @@ - (void)tryUpdatingActiveStyles {
updateNeeded = YES;
}
+ // detect custom style change
+ CustomStyle *customStyle = stylesDict[@([CustomStyle getType])];
+ CustomStyleData *currentCustomStyle =
+ [customStyle getCustomStyleDataAt:textView.selectedRange.location];
+ if (currentCustomStyle != _recentlyEmittedCustomStyle &&
+ ![currentCustomStyle isEqual:_recentlyEmittedCustomStyle]) {
+ updateNeeded = YES;
+ }
+
if (updateNeeded) {
auto emitter = [self getEventEmitter];
if (emitter != nullptr) {
@@ -1127,6 +1139,7 @@ - (void)tryUpdatingActiveStyles {
_activeStyles = newActiveStyles;
_blockedStyles = newBlockedStyles;
_recentlyEmittedAlignment = currentAlignment;
+ _recentlyEmittedCustomStyle = currentCustomStyle;
emitter->onChangeState(
{.bold = GET_STYLE_STATE([BoldStyle getType]),
@@ -1148,7 +1161,14 @@ - (void)tryUpdatingActiveStyles {
.codeBlock = GET_STYLE_STATE([CodeBlockStyle getType]),
.image = GET_STYLE_STATE([ImageStyle getType]),
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]),
- .alignment = [currentAlignment UTF8String]});
+ .alignment = [currentAlignment UTF8String],
+ .customStyle = {
+ .foregroundColor =
+ [[currentCustomStyle.foregroundColor hexString] UTF8String]
+ ?: "",
+ .backgroundColor =
+ [[currentCustomStyle.backgroundColor hexString] UTF8String]
+ ?: ""}});
}
}
@@ -1296,6 +1316,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args {
if (!_placeholderLabel.isHidden) {
[self refreshPlaceholderLabelStyles];
}
+ } else if ([commandName isEqualToString:@"setStyle"]) {
+ NSString *styleJSON = (NSString *)args[0];
+ [self setStyle:styleJSON];
}
}
@@ -1503,6 +1526,53 @@ - (void)toggleRegularStyle:(StyleType)type {
}
}
+- (void)setStyle:(NSString *)styleJSON {
+ NSData *jsonData = [styleJSON dataUsingEncoding:NSUTF8StringEncoding];
+ if (jsonData == nil)
+ return;
+ id parsed = [NSJSONSerialization JSONObjectWithData:jsonData
+ options:0
+ error:nil];
+ if (![parsed isKindOfClass:[NSDictionary class]])
+ return;
+ NSDictionary *dict = (NSDictionary *)parsed;
+
+ NSRange selectedRange = textView.selectedRange;
+ CustomStyle *customStyleClass =
+ (CustomStyle *)stylesDict[@([CustomStyle getType])];
+ if (customStyleClass == nil)
+ return;
+
+ if (![StyleUtils handleStyleBlocksAndConflicts:[CustomStyle getType]
+ range:selectedRange
+ forHost:self]) {
+ return;
+ }
+
+ // Convert raw JSON values (NSNumber ARGB integers from processColor) to
+ // UIColor. NSNull is passed through as-is so mergeFromDict: can clear
+ // the color when the caller explicitly passes null.
+ NSMutableDictionary *processedDict = [NSMutableDictionary new];
+
+ id fgRaw = dict[@"foregroundColor"];
+ if (fgRaw != nil) {
+ processedDict[@"foregroundColor"] = [fgRaw isKindOfClass:[NSNull class]]
+ ? [NSNull null]
+ : [RCTConvert UIColor:fgRaw];
+ }
+
+ id bgRaw = dict[@"backgroundColor"];
+ if (bgRaw != nil) {
+ processedDict[@"backgroundColor"] = [bgRaw isKindOfClass:[NSNull class]]
+ ? [NSNull null]
+ : [RCTConvert UIColor:bgRaw];
+ }
+
+ [customStyleClass applyStyleFromDict:processedDict
+ selectedRange:selectedRange];
+ [self anyTextMayHaveBeenModified];
+}
+
- (void)toggleCheckboxList:(BOOL)checked {
CheckboxListStyle *style =
(CheckboxListStyle *)stylesDict[@([CheckboxListStyle getType])];
@@ -1855,6 +1925,10 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText {
AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])];
NSString *currentAlignment = [alignmentStyle getStyleState];
+ CustomStyle *customStyle = stylesDict[@([CustomStyle getType])];
+ CustomStyleData *contextCustomStyleData =
+ [customStyle getCustomStyleDataAt:textView.selectedRange.location];
+
emitter->onContextMenuItemPress(
{.itemText = [itemText toCppString],
.selectedText = [selectedText toCppString],
@@ -1881,7 +1955,16 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText {
.image = GET_STYLE_STATE([ImageStyle getType]),
.mention = GET_STYLE_STATE([MentionStyle getType]),
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]),
- .alignment = [currentAlignment UTF8String]}});
+ .alignment = [currentAlignment UTF8String],
+ .customStyle = {
+ .foregroundColor =
+ [[contextCustomStyleData.foregroundColor hexString]
+ UTF8String]
+ ?: "",
+ .backgroundColor =
+ [[contextCustomStyleData.backgroundColor hexString]
+ UTF8String]
+ ?: ""}}});
}
}
@@ -1908,8 +1991,10 @@ - (bool)textView:(UITextView *)textView
replacementText:(NSString *)text {
// Capture the attributes at range.location that are being replaced
// (autocorrect / predictive) so didProcessEditing: can re-stamp them onto the
- // replacement.
- if (range.length > 0) {
+ // replacement. Skip pure deletions (text.length == 0) — there is no incoming
+ // text to receive attributes, and capturing here would cause the deleted
+ // character's CustomStyleData to be re-stamped onto the widened editedRange.
+ if (range.length > 0 && text.length > 0) {
_capturedAttributesBeforeChange =
[textView.textStorage attributesAtIndex:range.location
effectiveRange:NULL];
diff --git a/ios/customStyleData/CustomStyleData.h b/ios/customStyleData/CustomStyleData.h
new file mode 100644
index 00000000..7ee405f8
--- /dev/null
+++ b/ios/customStyleData/CustomStyleData.h
@@ -0,0 +1,15 @@
+#pragma once
+#import
+
+@interface CustomStyleData : NSObject
+
+@property(nonatomic, strong, nullable) UIColor *foregroundColor;
+@property(nonatomic, strong, nullable) UIColor *backgroundColor;
+
+- (BOOL)isEmpty;
+
+// Applies a partial update from a dict. A key absent from the dict leaves the
+// field unchanged; NSNull value clears it.
+- (void)mergeFromDict:(NSDictionary *)dict;
+
+@end
diff --git a/ios/customStyleData/CustomStyleData.mm b/ios/customStyleData/CustomStyleData.mm
new file mode 100644
index 00000000..c08f1fd5
--- /dev/null
+++ b/ios/customStyleData/CustomStyleData.mm
@@ -0,0 +1,46 @@
+#import "CustomStyleData.h"
+
+@implementation CustomStyleData
+
+- (BOOL)isEmpty {
+ return _foregroundColor == nil && _backgroundColor == nil;
+}
+
+- (void)mergeFromDict:(NSDictionary *)dict {
+ id fgVal = dict[@"foregroundColor"];
+ if (fgVal != nil) {
+ self.foregroundColor =
+ [fgVal isKindOfClass:[UIColor class]] ? (UIColor *)fgVal : nil;
+ }
+ id bgVal = dict[@"backgroundColor"];
+ if (bgVal != nil) {
+ self.backgroundColor =
+ [bgVal isKindOfClass:[UIColor class]] ? (UIColor *)bgVal : nil;
+ }
+}
+
+- (BOOL)isEqual:(id)object {
+ if (self == object)
+ return YES;
+ if (![object isKindOfClass:[CustomStyleData class]])
+ return NO;
+ CustomStyleData *other = (CustomStyleData *)object;
+ BOOL fgEqual = (_foregroundColor == other.foregroundColor) ||
+ [_foregroundColor isEqual:other.foregroundColor];
+ BOOL bgEqual = (_backgroundColor == other.backgroundColor) ||
+ [_backgroundColor isEqual:other.backgroundColor];
+ return fgEqual && bgEqual;
+}
+
+- (NSUInteger)hash {
+ return [_foregroundColor hash] ^ [_backgroundColor hash];
+}
+
+- (id)copyWithZone:(NSZone *)zone {
+ CustomStyleData *copy = [[CustomStyleData allocWithZone:zone] init];
+ copy.foregroundColor = self.foregroundColor;
+ copy.backgroundColor = self.backgroundColor;
+ return copy;
+}
+
+@end
diff --git a/ios/extensions/ColorExtension.h b/ios/extensions/ColorExtension.h
index c3a2323c..33550251 100644
--- a/ios/extensions/ColorExtension.h
+++ b/ios/extensions/ColorExtension.h
@@ -5,4 +5,7 @@
- (BOOL)isEqualToColor:(UIColor *)otherColor;
- (UIColor *)colorWithResolvedAlpha;
- (UIColor *)colorWithResolvedAlpha:(CGFloat)newAlpha;
+- (NSString *)hexString;
+
++ (UIColor *_Nullable)colorFromCSSString:(NSString *_Nullable)cssString;
@end
diff --git a/ios/extensions/ColorExtension.mm b/ios/extensions/ColorExtension.mm
index 32877787..8c1f7ff9 100644
--- a/ios/extensions/ColorExtension.mm
+++ b/ios/extensions/ColorExtension.mm
@@ -1,5 +1,163 @@
#import "ColorExtension.h"
+static NSDictionary *getNamedHexColors(void) {
+ static NSDictionary *namedColorHexes = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ namedColorHexes = @{
+ @"aliceblue" : @"#F0F8FFFF",
+ @"antiquewhite" : @"#FAEBD7FF",
+ @"aqua" : @"#00FFFFFF",
+ @"aquamarine" : @"#7FFFD4FF",
+ @"azure" : @"#F0FFFFFF",
+ @"beige" : @"#F5F5DCFF",
+ @"bisque" : @"#FFE4C4FF",
+ @"black" : @"#000000FF",
+ @"blanchedalmond" : @"#FFEBCDFF",
+ @"blue" : @"#0000FFFF",
+ @"blueviolet" : @"#8A2BE2FF",
+ @"brown" : @"#A52A2AFF",
+ @"burlywood" : @"#DEB887FF",
+ @"cadetblue" : @"#5F9EA0FF",
+ @"chartreuse" : @"#7FFF00FF",
+ @"chocolate" : @"#D2691EFF",
+ @"coral" : @"#FF7F50FF",
+ @"cornflowerblue" : @"#6495EDFF",
+ @"cornsilk" : @"#FFF8DCFF",
+ @"crimson" : @"#DC143CFF",
+ @"cyan" : @"#00FFFFFF",
+ @"darkblue" : @"#00008BFF",
+ @"darkcyan" : @"#008B8BFF",
+ @"darkgoldenrod" : @"#B8860BFF",
+ @"darkgray" : @"#A9A9A9FF",
+ @"darkgrey" : @"#A9A9A9FF",
+ @"darkgreen" : @"#006400FF",
+ @"darkkhaki" : @"#BDB76BFF",
+ @"darkmagenta" : @"#8B008BFF",
+ @"darkolivegreen" : @"#556B2FFF",
+ @"darkorange" : @"#FF8C00FF",
+ @"darkorchid" : @"#9932CCFF",
+ @"darkred" : @"#8B0000FF",
+ @"darksalmon" : @"#E9967AFF",
+ @"darkseagreen" : @"#8FBC8FFF",
+ @"darkslateblue" : @"#483D8BFF",
+ @"darkslategray" : @"#2F4F4FFF",
+ @"darkslategrey" : @"#2F4F4FFF",
+ @"darkturquoise" : @"#00CED1FF",
+ @"darkviolet" : @"#9400D3FF",
+ @"deeppink" : @"#FF1493FF",
+ @"deepskyblue" : @"#00BFFFFF",
+ @"dimgray" : @"#696969FF",
+ @"dimgrey" : @"#696969FF",
+ @"dodgerblue" : @"#1E90FFFF",
+ @"firebrick" : @"#B22222FF",
+ @"floralwhite" : @"#FFFAF0FF",
+ @"forestgreen" : @"#228B22FF",
+ @"fuchsia" : @"#FF00FFFF",
+ @"gainsboro" : @"#DCDCDCFF",
+ @"ghostwhite" : @"#F8F8FFFF",
+ @"gold" : @"#FFD700FF",
+ @"goldenrod" : @"#DAA520FF",
+ @"gray" : @"#808080FF",
+ @"grey" : @"#808080FF",
+ @"green" : @"#008000FF",
+ @"greenyellow" : @"#ADFF2FFF",
+ @"honeydew" : @"#F0FFF0FF",
+ @"hotpink" : @"#FF69B4FF",
+ @"indianred" : @"#CD5C5CFF",
+ @"indigo" : @"#4B0082FF",
+ @"ivory" : @"#FFFFF0FF",
+ @"khaki" : @"#F0E68CFF",
+ @"lavender" : @"#E6E6FAFF",
+ @"lavenderblush" : @"#FFF0F5FF",
+ @"lawngreen" : @"#7CFC00FF",
+ @"lemonchiffon" : @"#FFFACDFF",
+ @"lightblue" : @"#ADD8E6FF",
+ @"lightcoral" : @"#F08080FF",
+ @"lightcyan" : @"#E0FFFFFF",
+ @"lightgoldenrodyellow" : @"#FAFAD2FF",
+ @"lightgray" : @"#D3D3D3FF",
+ @"lightgrey" : @"#D3D3D3FF",
+ @"lightgreen" : @"#90EE90FF",
+ @"lightpink" : @"#FFB6C1FF",
+ @"lightsalmon" : @"#FFA07AFF",
+ @"lightseagreen" : @"#20B2AAFF",
+ @"lightskyblue" : @"#87CEFAFF",
+ @"lightslategray" : @"#778899FF",
+ @"lightslategrey" : @"#778899FF",
+ @"lightsteelblue" : @"#B0C4DEFF",
+ @"lightyellow" : @"#FFFFE0FF",
+ @"lime" : @"#00FF00FF",
+ @"limegreen" : @"#32CD32FF",
+ @"linen" : @"#FAF0E6FF",
+ @"magenta" : @"#FF00FFFF",
+ @"maroon" : @"#800000FF",
+ @"mediumaquamarine" : @"#66CDAAFF",
+ @"mediumblue" : @"#0000CDFF",
+ @"mediumorchid" : @"#BA55D3FF",
+ @"mediumpurple" : @"#9370D8FF",
+ @"mediumseagreen" : @"#3CB371FF",
+ @"mediumslateblue" : @"#7B68EEFF",
+ @"mediumspringgreen" : @"#00FA9AFF",
+ @"mediumturquoise" : @"#48D1CCFF",
+ @"mediumvioletred" : @"#C71585FF",
+ @"midnightblue" : @"#191970FF",
+ @"mintcream" : @"#F5FFFAFF",
+ @"mistyrose" : @"#FFE4E1FF",
+ @"moccasin" : @"#FFE4B5FF",
+ @"navajowhite" : @"#FFDEADFF",
+ @"navy" : @"#000080FF",
+ @"oldlace" : @"#FDF5E6FF",
+ @"olive" : @"#808000FF",
+ @"olivedrab" : @"#6B8E23FF",
+ @"orange" : @"#FFA500FF",
+ @"orangered" : @"#FF4500FF",
+ @"orchid" : @"#DA70D6FF",
+ @"palegoldenrod" : @"#EEE8AAFF",
+ @"palegreen" : @"#98FB98FF",
+ @"paleturquoise" : @"#AFEEEEFF",
+ @"palevioletred" : @"#D87093FF",
+ @"papayawhip" : @"#FFEFD5FF",
+ @"peachpuff" : @"#FFDAB9FF",
+ @"peru" : @"#CD853FFF",
+ @"pink" : @"#FFC0CBFF",
+ @"plum" : @"#DDA0DDFF",
+ @"powderblue" : @"#B0E0E6FF",
+ @"purple" : @"#800080FF",
+ @"rebeccapurple" : @"#663399FF",
+ @"red" : @"#FF0000FF",
+ @"rosybrown" : @"#BC8F8FFF",
+ @"royalblue" : @"#4169E1FF",
+ @"saddlebrown" : @"#8B4513FF",
+ @"salmon" : @"#FA8072FF",
+ @"sandybrown" : @"#F4A460FF",
+ @"seagreen" : @"#2E8B57FF",
+ @"seashell" : @"#FFF5EEFF",
+ @"sienna" : @"#A0522DFF",
+ @"silver" : @"#C0C0C0FF",
+ @"skyblue" : @"#87CEEBFF",
+ @"slateblue" : @"#6A5ACDFF",
+ @"slategray" : @"#708090FF",
+ @"slategrey" : @"#708090FF",
+ @"snow" : @"#FFFAFAFF",
+ @"springgreen" : @"#00FF7FFF",
+ @"steelblue" : @"#4682B4FF",
+ @"tan" : @"#D2B48CFF",
+ @"teal" : @"#008080FF",
+ @"thistle" : @"#D8BFD8FF",
+ @"tomato" : @"#FF6347FF",
+ @"turquoise" : @"#40E0D0FF",
+ @"violet" : @"#EE82EEFF",
+ @"wheat" : @"#F5DEB3FF",
+ @"white" : @"#FFFFFFFF",
+ @"whitesmoke" : @"#F5F5F5FF",
+ @"yellow" : @"#FFFF00FF",
+ @"yellowgreen" : @"#9ACD32FF"
+ };
+ });
+ return namedColorHexes;
+}
+
@implementation UIColor (ColorExtension)
- (BOOL)isEqualToColor:(UIColor *)otherColor {
CGColorSpaceRef colorSpaceRGB = CGColorSpaceCreateDeviceRGB();
@@ -45,4 +203,127 @@ - (UIColor *)colorWithResolvedAlpha:(CGFloat)newAlpha {
}
return self;
}
+
+// Returns a CSS hex color string.
+// Opaque colors produce 6-digit form (#RRGGBB); semi-transparent produce
+// 8-digit form (#RRGGBBAA). Returns @"" if the color cannot be expressed
+// in RGB.
+- (NSString *)hexString {
+ CGFloat red = 0.0;
+ CGFloat green = 0.0;
+ CGFloat blue = 0.0;
+ CGFloat alpha = 0.0;
+
+ if (![self getRed:&red green:&green blue:&blue alpha:&alpha])
+ return @"";
+
+ int r = (int)(red * 255.0 + 0.5);
+ int g = (int)(green * 255.0 + 0.5);
+ int b = (int)(blue * 255.0 + 0.5);
+ int a = (int)(alpha * 255.0 + 0.5);
+
+ if (a == 255)
+ return [NSString stringWithFormat:@"#%02X%02X%02X", r, g, b];
+ return [NSString stringWithFormat:@"#%02X%02X%02X%02X", r, g, b, a];
+}
+
+// Converts a CSS color string (Hex, RGB, RGBA, or Named) into a UIColor.
++ (UIColor *_Nullable)colorFromCSSString:(NSString *_Nullable)cssString {
+ if (cssString.length == 0)
+ return nil;
+
+ // Trim whitespace and force lowercase for easier matching
+ NSString *str =
+ [cssString
+ stringByTrimmingCharactersInSet:[NSCharacterSet
+ whitespaceAndNewlineCharacterSet]]
+ .lowercaseString;
+
+ // Handle Hex (#FFF, #FFFFFF, #FFFFFFFF)
+ if ([str hasPrefix:@"#"]) {
+ str = [str substringFromIndex:1];
+ NSUInteger len = str.length;
+
+ unsigned int value = 0;
+ NSScanner *scanner = [NSScanner scannerWithString:str];
+ if (![scanner scanHexInt:&value])
+ return nil;
+
+ CGFloat r, g, b, a = 1.0;
+
+ if (len == 3) {
+ r = ((value >> 8) & 0xF) / 15.0;
+ g = ((value >> 4) & 0xF) / 15.0;
+ b = (value & 0xF) / 15.0;
+ } else if (len == 6) {
+ r = ((value >> 16) & 0xFF) / 255.0;
+ g = ((value >> 8) & 0xFF) / 255.0;
+ b = (value & 0xFF) / 255.0;
+ } else if (len == 8) {
+ r = ((value >> 24) & 0xFF) / 255.0;
+ g = ((value >> 16) & 0xFF) / 255.0;
+ b = ((value >> 8) & 0xFF) / 255.0;
+ a = (value & 0xFF) / 255.0;
+ } else {
+ return nil; // Invalid hex length
+ }
+
+ return [UIColor colorWithRed:r green:g blue:b alpha:a];
+ }
+
+ // Handle rgb() and rgba()
+ if ([str hasPrefix:@"rgb"]) {
+ NSScanner *scanner = [NSScanner scannerWithString:str];
+
+ // Scan up to and including the opening parenthesis
+ [scanner scanUpToString:@"(" intoString:NULL];
+ if (![scanner scanString:@"(" intoString:NULL])
+ return nil;
+
+ float r = 0, g = 0, b = 0, a = 1.0;
+
+ // Scan Red, then require a comma
+ if (![scanner scanFloat:&r])
+ return nil;
+ if (![scanner scanString:@"," intoString:NULL])
+ return nil;
+
+ // Scan Green, then require a comma
+ if (![scanner scanFloat:&g])
+ return nil;
+ if (![scanner scanString:@"," intoString:NULL])
+ return nil;
+
+ // Scan Blue (comma not required yet, might be alpha or closing parenthesis)
+ if (![scanner scanFloat:&b])
+ return nil;
+
+ // Check if there is a 4th parameter (Alpha)
+ if ([scanner scanString:@"," intoString:NULL]) {
+ if (![scanner scanFloat:&a])
+ return nil;
+ }
+
+ // Require the closing parenthesis to guarantee the string wasn't malformed
+ // or cut off
+ if (![scanner scanString:@")" intoString:NULL])
+ return nil;
+
+ return [UIColor colorWithRed:r / 255.0
+ green:g / 255.0
+ blue:b / 255.0
+ alpha:a];
+ }
+
+ // Handle Named Colors
+ NSString *hexForName = getNamedHexColors()[str];
+ if (hexForName) {
+ // We found a match! Pass the 8-digit hex string right back into this very
+ // method to reuse the Hex parsing logic.
+ return [self colorFromCSSString:hexForName];
+ }
+
+ return nil;
+}
+
@end
diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm
index a6e84dd6..50e2b1a6 100644
--- a/ios/htmlParser/HtmlParser.mm
+++ b/ios/htmlParser/HtmlParser.mm
@@ -1,6 +1,9 @@
#import "HtmlParser.h"
#import "AlignmentEntry.h"
#import "AlignmentUtils.h"
+#import "ColorExtension.h"
+#import "CustomStyleData.h"
+#include "GumboParser.hpp"
#import "ImageData.h"
#import "LinkData.h"
#import "MentionParams.h"
@@ -8,8 +11,6 @@
#import "StyleHeaders.h"
#import "StylePair.h"
-#include "GumboParser.hpp"
-
@implementation HtmlParser
+ (BOOL)isBlockTag:(NSString *)tagName {
@@ -41,9 +42,10 @@ + (BOOL)isBlockTag:(NSString *)tagName {
* you MUST add it to the `textTags` set below.
*/
+ (NSString *)stripExtraWhiteSpacesAndNewlines:(NSString *)html {
- NSSet *textTags = [NSSet setWithObjects:@"p", @"h1", @"h2", @"h3", @"h4",
- @"h5", @"h6", @"li", @"b", @"a", @"s",
- @"mention", @"code", @"u", @"i", nil];
+ NSSet *textTags =
+ [NSSet setWithObjects:@"p", @"h1", @"h2", @"h3", @"h4", @"h5", @"h6",
+ @"li", @"b", @"a", @"s", @"mention", @"code", @"u",
+ @"i", @"span", nil];
NSMutableString *output = [NSMutableString stringWithCapacity:html.length];
NSMutableString *currentTagBuffer = [NSMutableString string];
@@ -817,9 +819,15 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml {
[styleArr addObject:@([BlockQuoteStyle getType])];
} else if ([tagName isEqualToString:@"codeblock"]) {
[styleArr addObject:@([CodeBlockStyle getType])];
+ } else if ([tagName isEqualToString:@"span"]) {
+ CustomStyleData *data = [self parseCustomStyleDataFromSpanParams:params];
+ if (data == nil || data.isEmpty) {
+ continue;
+ }
+ [styleArr addObject:@([CustomStyle getType])];
+ stylePair.styleValue = data;
} else {
- // some other external tags like span just don't get put into the
- // processed styles
+ // some other external tags don't get put into the processed styles
continue;
}
@@ -849,6 +857,7 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range
BOOL inCodeBlock = NO;
BOOL inCheckboxList = NO;
unichar lastCharacter = 0;
+ CustomStyleData *lastCustomStyleData = nil;
for (int i = 0; i < text.length; i++) {
NSRange currentRange = NSMakeRange(offset + i, 1);
@@ -988,6 +997,7 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range
// clear the previous styles
previousActiveStyles = [[NSSet alloc] init];
+ lastCustomStyleData = nil;
// next character opens new paragraph
newLine = YES;
@@ -1149,6 +1159,36 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range
}
}
+ // Force close+reopen if CustomStyle is continuously active but its data
+ // changed, so adjacent runs with different styles produce separate
+ // tags instead of being merged into one.
+ NSNumber *customType = @([CustomStyle getType]);
+ if (![endedStyles member:customType] &&
+ [currentActiveStyles member:customType] &&
+ [previousActiveStyles member:customType]) {
+ CustomStyle *customStyleObj =
+ (CustomStyle *)host.stylesDict[customType];
+ CustomStyleData *currentData =
+ [customStyleObj getStoredCustomStyleDataAt:currentRange.location];
+ if (![currentData isEqual:lastCustomStyleData]) {
+ [fixedEndedStyles addObject:customType];
+ [stylesToBeReAdded addObject:customType];
+
+ // Inner styles (e.g. bold) must also close before the span ends and
+ // reopen inside the new span, otherwise tags cross span boundaries.
+ for (NSNumber *activeStyle in currentActiveStyles) {
+ if ([activeStyle isEqualToNumber:customType] ||
+ [activeStyle isEqualToNumber:@([ImageStyle getType])]) {
+ continue;
+ }
+ if ([activeStyle integerValue] > [customType integerValue]) {
+ [fixedEndedStyles addObject:activeStyle];
+ [stylesToBeReAdded addObject:activeStyle];
+ }
+ }
+ }
+ }
+
// they are sorted in a descending order
NSArray *sortedEndedStyles = [fixedEndedStyles
sortedArrayUsingDescriptors:@[ [NSSortDescriptor
@@ -1194,6 +1234,11 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range
// append the letter and escape it if needed
[result appendString:[NSString stringByEscapingHtml:currentCharacterStr]];
+ // track CustomStyleData for the next character's data-change check
+ lastCustomStyleData =
+ [(CustomStyle *)host.stylesDict[@([CustomStyle getType])]
+ getStoredCustomStyleDataAt:currentRange.location];
+
// save current styles for next character's checks
previousActiveStyles = currentActiveStyles;
}
@@ -1413,6 +1458,34 @@ + (NSString *)tagContentForStyle:(NSNumber *)style
[style isEqualToNumber:@([CodeBlockStyle getType])]) {
// blockquotes and codeblock use tags the same way lists use
return [NSString stringWithFormat:@"p%@", cssStyleString];
+ } else if ([style isEqualToNumber:@([CustomStyle getType])]) {
+ if (openingTag) {
+ CustomStyle *customStyle =
+ (CustomStyle *)host.stylesDict[@([CustomStyle getType])];
+ if (customStyle != nil) {
+ CustomStyleData *data =
+ [customStyle getStoredCustomStyleDataAt:location];
+ if (data != nil && !data.isEmpty) {
+ NSMutableString *cssProps = [NSMutableString string];
+ NSString *fg = [[data foregroundColor] hexString];
+ NSString *bg = [[data backgroundColor] hexString];
+ if (fg.length > 0) {
+ [cssProps appendFormat:@"color: %@;", fg];
+ }
+ if (bg.length > 0) {
+ if (cssProps.length > 0)
+ [cssProps appendString:@" "];
+ [cssProps appendFormat:@"background-color: %@;", bg];
+ }
+ if (cssProps.length > 0) {
+ return [NSString stringWithFormat:@"span style=\"%@\"", cssProps];
+ }
+ }
+ }
+ return @"span";
+ } else {
+ return @"span";
+ }
}
return @"";
}
@@ -1437,6 +1510,62 @@ + (NSString *)prepareCssStyleString:(NSInteger)location
return @"";
}
++ (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams:
+ (NSString *)params {
+ static NSRegularExpression *styleAttrRegex;
+ static NSRegularExpression *fgRegex;
+ static NSRegularExpression *bgRegex;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ styleAttrRegex = [NSRegularExpression
+ regularExpressionWithPattern:@"style\\s*=\\s*[\"']([^\"']*)[\"']"
+ options:NSRegularExpressionCaseInsensitive
+ error:nil];
+ // Captures everything after "color:" until a semicolon or end of string
+ fgRegex = [NSRegularExpression
+ regularExpressionWithPattern:@"(?:^|;)\\s*color\\s*:\\s*([^;]+)"
+ options:NSRegularExpressionCaseInsensitive
+ error:nil];
+
+ // Captures everything after "background-color:" until a semicolon or end of
+ // string
+ bgRegex = [NSRegularExpression
+ regularExpressionWithPattern:@"background-color\\s*:\\s*([^;]+)"
+ options:NSRegularExpressionCaseInsensitive
+ error:nil];
+ });
+
+ NSTextCheckingResult *attrMatch =
+ [styleAttrRegex firstMatchInString:params
+ options:0
+ range:NSMakeRange(0, params.length)];
+ if (!attrMatch)
+ return nil;
+
+ NSString *css = [params substringWithRange:[attrMatch rangeAtIndex:1]];
+ CustomStyleData *data = [[CustomStyleData alloc] init];
+
+ NSTextCheckingResult *fgMatch =
+ [fgRegex firstMatchInString:css
+ options:0
+ range:NSMakeRange(0, css.length)];
+ if (fgMatch) {
+ data.foregroundColor = [UIColor
+ colorFromCSSString:[css substringWithRange:[fgMatch rangeAtIndex:1]]];
+ }
+
+ NSTextCheckingResult *bgMatch =
+ [bgRegex firstMatchInString:css
+ options:0
+ range:NSMakeRange(0, css.length)];
+ if (bgMatch) {
+ data.backgroundColor = [UIColor
+ colorFromCSSString:[css substringWithRange:[bgMatch rangeAtIndex:1]]];
+ }
+
+ return data.isEmpty ? nil : data;
+}
+
+ (void)checkForAlignments:(NSArray *)tagData
plainText:(NSString *)plainText
foundAlignments:(NSMutableArray *)foundAlignments
diff --git a/ios/inputAttributesManager/InputAttributesManager.mm b/ios/inputAttributesManager/InputAttributesManager.mm
index fc07d871..02b953ea 100644
--- a/ios/inputAttributesManager/InputAttributesManager.mm
+++ b/ios/inputAttributesManager/InputAttributesManager.mm
@@ -95,17 +95,20 @@ - (void)handleDirtyRangesStyling {
[ZeroWidthSpaceUtils applyKernForZeroWidthSpacesInRange:dirtyRange
host:_input];
- // Sort style types so paragraph styles come first. Their broad visual
- // attributes (e.g. foreground color, font) are laid down before inline
- // styles override them on their specific sub-ranges.
+ // Sort style types by priority (0=paragraph, 1=custom, 2=inline) so
+ // paragraph styles come first. Their broad visual attributes (e.g.
+ // foreground color, font) are laid down before custom and inline styles
+ // override them on their specific sub-ranges.
NSArray *sortedStyleTypes = [presentStyles.allKeys
sortedArrayUsingComparator:^NSComparisonResult(NSNumber *a,
NSNumber *b) {
- BOOL aPara = [_input->stylesDict[a] isParagraph];
- BOOL bPara = [_input->stylesDict[b] isParagraph];
- if (aPara == bPara)
- return NSOrderedSame;
- return aPara ? NSOrderedAscending : NSOrderedDescending;
+ NSInteger aPriority = [_input->stylesDict[a] stylePriority];
+ NSInteger bPriority = [_input->stylesDict[b] stylePriority];
+ if (aPriority < bPriority)
+ return NSOrderedAscending;
+ if (aPriority > bPriority)
+ return NSOrderedDescending;
+ return NSOrderedSame;
}];
// re-apply meta-attributes and apply visual styling following the saved
diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm
index bc9e5875..f66e8e55 100644
--- a/ios/inputHtmlParser/InputHtmlParser.mm
+++ b/ios/inputHtmlParser/InputHtmlParser.mm
@@ -179,6 +179,12 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles
}
}
}
+ } else if ([styleType isEqualToNumber:@([CustomStyle getType])]) {
+ CustomStyle *customStyle = (CustomStyle *)baseStyle;
+ [customStyle setCustomStyleData:stylePair.styleValue
+ range:styleRange
+ withTyping:shouldAddTypingAttr
+ withDirtyRange:YES];
} else {
[baseStyle add:styleRange
withTyping:shouldAddTypingAttr
diff --git a/ios/interfaces/EnrichedTextStyleHeaders.h b/ios/interfaces/EnrichedTextStyleHeaders.h
index 008f0fce..6d1a73af 100644
--- a/ios/interfaces/EnrichedTextStyleHeaders.h
+++ b/ios/interfaces/EnrichedTextStyleHeaders.h
@@ -34,6 +34,9 @@
@interface EnrichedTextH6Style : H6Style
@end
+@interface EnrichedTextCustomStyle : CustomStyle
+@end
+
@interface EnrichedTextBlockQuoteStyle : BlockQuoteStyle
@end
diff --git a/ios/interfaces/StyleBase.h b/ios/interfaces/StyleBase.h
index 41a3d27d..5075c551 100644
--- a/ios/interfaces/StyleBase.h
+++ b/ios/interfaces/StyleBase.h
@@ -12,6 +12,7 @@
- (NSString *)getValue;
- (NSString *)getMarkerPrefix;
- (BOOL)isParagraph;
+- (NSInteger)stylePriority;
- (BOOL)needsZWS;
- (BOOL)appliesStylingToTyping;
- (instancetype)initWithHost:(id)host;
diff --git a/ios/interfaces/StyleBase.mm b/ios/interfaces/StyleBase.mm
index f43c5322..1c0fac94 100644
--- a/ios/interfaces/StyleBase.mm
+++ b/ios/interfaces/StyleBase.mm
@@ -37,6 +37,13 @@ - (BOOL)isParagraph {
return false;
}
+// Returns the application priority for this style.
+// 0 = paragraph, 1 = custom, 2 = inline (default).
+// Styles are applied in ascending priority order so inline styles win.
+- (NSInteger)stylePriority {
+ return [self isParagraph] ? 0 : 2;
+}
+
- (BOOL)needsZWS {
return NO;
}
diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h
index 1a16d381..510448f2 100644
--- a/ios/interfaces/StyleHeaders.h
+++ b/ios/interfaces/StyleHeaders.h
@@ -1,9 +1,20 @@
#pragma once
+#import "CustomStyleData.h"
#import "ImageData.h"
#import "LinkData.h"
#import "MentionParams.h"
#import "StyleBase.h"
+@interface CustomStyle : StyleBase
+- (void)applyStyleFromDict:(NSDictionary *)dict selectedRange:(NSRange)range;
+- (void)setCustomStyleData:(CustomStyleData *)data
+ range:(NSRange)range
+ withTyping:(BOOL)withTyping
+ withDirtyRange:(BOOL)withDirtyRange;
+- (CustomStyleData *_Nullable)getCustomStyleDataAt:(NSUInteger)location;
+- (CustomStyleData *_Nullable)getStoredCustomStyleDataAt:(NSUInteger)location;
+@end
+
@interface BoldStyle : StyleBase
@end
diff --git a/ios/interfaces/StyleTypeEnum.h b/ios/interfaces/StyleTypeEnum.h
index 701d79ea..a5c163fc 100644
--- a/ios/interfaces/StyleTypeEnum.h
+++ b/ios/interfaces/StyleTypeEnum.h
@@ -15,6 +15,7 @@ typedef NS_ENUM(NSInteger, StyleType) {
H4,
H5,
H6,
+ Custom,
Link,
Mention,
Image,
diff --git a/ios/styles/CustomStyle.mm b/ios/styles/CustomStyle.mm
new file mode 100644
index 00000000..45def651
--- /dev/null
+++ b/ios/styles/CustomStyle.mm
@@ -0,0 +1,209 @@
+#import "CustomStyleData.h"
+#import "EnrichedTextInputView.h"
+#import "RangeUtils.h"
+#import "StyleHeaders.h"
+
+static NSString *const CustomStyleAttributeName = @"EnrichedCustomStyle";
+
+@implementation CustomStyle
+
++ (StyleType)getType {
+ return Custom;
+}
+
+- (NSString *)getKey {
+ return CustomStyleAttributeName;
+}
+
+- (BOOL)isParagraph {
+ return NO;
+}
+
+- (NSInteger)stylePriority {
+ return 1;
+}
+
+- (BOOL)styleCondition:(id)value range:(NSRange)range {
+ if (![value isKindOfClass:[CustomStyleData class]])
+ return NO;
+ return ![(CustomStyleData *)value isEmpty];
+}
+
+- (void)applyStyling:(NSRange)range {
+ if (range.length == 0)
+ return;
+
+ NSUInteger storageLength = self.host.textView.textStorage.length;
+ if (storageLength == 0)
+ return;
+
+ NSRange safeRange = NSMakeRange(
+ range.location, MIN(range.length, storageLength - range.location));
+
+ // Enumerate each sub-range that carries its own CustomStyleData so that
+ // characters with different data values each get the correct visual attrs.
+ [self.host.textView.textStorage
+ enumerateAttribute:CustomStyleAttributeName
+ inRange:safeRange
+ options:0
+ usingBlock:^(id value, NSRange subRange, BOOL *stop) {
+ if (![value isKindOfClass:[CustomStyleData class]])
+ return;
+ CustomStyleData *data = (CustomStyleData *)value;
+ if (data.isEmpty)
+ return;
+
+ NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
+ if (data.foregroundColor != nil) {
+ attrs[NSForegroundColorAttributeName] = data.foregroundColor;
+ attrs[NSUnderlineColorAttributeName] = data.foregroundColor;
+ attrs[NSStrikethroughColorAttributeName] =
+ data.foregroundColor;
+ }
+ if (data.backgroundColor != nil) {
+ attrs[NSBackgroundColorAttributeName] = data.backgroundColor;
+ }
+ if (attrs.count == 0)
+ return;
+
+ // 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]];
+ }
+ }];
+}
+
+- (void)reapplyFromStylePair:(StylePair *)pair {
+ NSRange range = [pair.rangeValue rangeValue];
+ CustomStyleData *data = (CustomStyleData *)pair.styleValue;
+ if (data == nil || data.isEmpty)
+ return;
+ [self.host.textView.textStorage addAttribute:CustomStyleAttributeName
+ value:data
+ range:range];
+}
+
+- (AttributeEntry *)getEntryIfPresent:(NSRange)range {
+ CustomStyleData *data = [self getCustomStyleDataAt:range.location];
+ if (data == nil || data.isEmpty)
+ return nullptr;
+
+ AttributeEntry *entry = [[AttributeEntry alloc] init];
+ entry.key = CustomStyleAttributeName;
+ entry.value = data;
+ return entry;
+}
+
+// MARK: - Public non-standard methods
+
+- (void)setCustomStyleData:(CustomStyleData *)data
+ range:(NSRange)range
+ withTyping:(BOOL)withTyping
+ withDirtyRange:(BOOL)withDirtyRange {
+ if (range.length > 0) {
+ if (data == nil || data.isEmpty) {
+ [self remove:range withDirtyRange:withDirtyRange];
+ return;
+ }
+ [self.host.textView.textStorage addAttribute:CustomStyleAttributeName
+ value:data
+ range:range];
+ if (withDirtyRange) {
+ [self.host.attributesManager addDirtyRange:range];
+ }
+ }
+
+ if (withTyping) {
+ if (data == nil || data.isEmpty) {
+ [self removeTyping];
+ } else {
+ NSMutableDictionary *newTypingAttrs =
+ [self.host.textView.typingAttributes mutableCopy];
+ newTypingAttrs[CustomStyleAttributeName] = data;
+ self.host.textView.typingAttributes = newTypingAttrs;
+ }
+ }
+}
+
+- (CustomStyleData *_Nullable)getCustomStyleDataAt:(NSUInteger)location {
+ NSRange selectedRange = self.host.textView.selectedRange;
+ if (self.host.textView.isEditable && selectedRange.length == 0 &&
+ selectedRange.location == location) {
+ id typingValue =
+ self.host.textView.typingAttributes[CustomStyleAttributeName];
+ if ([typingValue isKindOfClass:[CustomStyleData class]])
+ return (CustomStyleData *)typingValue;
+ return nil;
+ }
+
+ return [self getStoredCustomStyleDataAt:location];
+}
+
+// Reads CustomStyleData directly from textStorage, bypassing typingAttributes.
+- (CustomStyleData *_Nullable)getStoredCustomStyleDataAt:(NSUInteger)location {
+ NSUInteger length = self.host.textView.textStorage.length;
+ if (length == 0)
+ return nil;
+ NSUInteger searchLocation = (location >= length) ? length - 1 : location;
+ id value = [self.host.textView.textStorage attribute:CustomStyleAttributeName
+ atIndex:searchLocation
+ longestEffectiveRange:nil
+ inRange:NSMakeRange(0, length)];
+ if (![value isKindOfClass:[CustomStyleData class]])
+ return nil;
+ return (CustomStyleData *)value;
+}
+
+- (void)applyStyleFromDict:(NSDictionary *)dict selectedRange:(NSRange)range {
+ BOOL withTyping = range.length == 0;
+
+ if (!withTyping) {
+ // Enumerate each existing sub-range and merge the partial update into its
+ // own data so per-character differences (e.g. fg color on some chars) are
+ // preserved when only one field (e.g. bg color) is being changed.
+ NSUInteger storageLength = self.host.textView.textStorage.length;
+ if (storageLength == 0)
+ return;
+
+ NSRange safeRange = NSMakeRange(
+ range.location, MIN(range.length, storageLength - range.location));
+
+ [self.host.textView.textStorage
+ enumerateAttribute:CustomStyleAttributeName
+ inRange:safeRange
+ options:0
+ usingBlock:^(id value, NSRange subRange, BOOL *stop) {
+ CustomStyleData *existing =
+ [value isKindOfClass:[CustomStyleData class]]
+ ? (CustomStyleData *)value
+ : nil;
+ CustomStyleData *merged =
+ existing != nil ? [existing copy]
+ : [[CustomStyleData alloc] init];
+ [merged mergeFromDict:dict];
+ [self setCustomStyleData:merged
+ range:subRange
+ withTyping:NO
+ withDirtyRange:YES];
+ }];
+ } else {
+ // Cursor only: merge into current data and update typing attributes.
+ CustomStyleData *existing = [self getCustomStyleDataAt:range.location];
+ CustomStyleData *merged =
+ existing != nil ? [existing copy] : [[CustomStyleData alloc] init];
+ [merged mergeFromDict:dict];
+ [self setCustomStyleData:merged
+ range:range
+ withTyping:YES
+ withDirtyRange:NO];
+ [self.host.attributesManager
+ didRemoveTypingAttribute:CustomStyleAttributeName];
+ }
+}
+
+@end
diff --git a/ios/styles/EnrichedTextStyles.mm b/ios/styles/EnrichedTextStyles.mm
index fde9abfa..f125f66d 100644
--- a/ios/styles/EnrichedTextStyles.mm
+++ b/ios/styles/EnrichedTextStyles.mm
@@ -33,6 +33,9 @@ @implementation EnrichedTextH5Style
@implementation EnrichedTextH6Style
@end
+@implementation EnrichedTextCustomStyle
+@end
+
@implementation EnrichedTextBlockQuoteStyle
@end
diff --git a/ios/textHtmlParser/TextHtmlParser.mm b/ios/textHtmlParser/TextHtmlParser.mm
index 1f930a01..bd1ea372 100644
--- a/ios/textHtmlParser/TextHtmlParser.mm
+++ b/ios/textHtmlParser/TextHtmlParser.mm
@@ -122,6 +122,12 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles {
}
}
}
+ } else if ([styleType isEqualToNumber:@([CustomStyle getType])]) {
+ CustomStyle *customStyle = (CustomStyle *)style;
+ [customStyle setCustomStyleData:stylePair.styleValue
+ range:styleRange
+ withTyping:NO
+ withDirtyRange:NO];
} else {
[style add:styleRange withTyping:NO withDirtyRange:NO];
}
diff --git a/ios/utils/StyleUtils.mm b/ios/utils/StyleUtils.mm
index 83826922..a3ef1b17 100644
--- a/ios/utils/StyleUtils.mm
+++ b/ios/utils/StyleUtils.mm
@@ -91,7 +91,8 @@ + (NSDictionary *)conflictMap {
@([CheckboxListStyle getType])
],
@([ImageStyle getType]) :
- @[ @([LinkStyle getType]), @([MentionStyle getType]) ]
+ @[ @([LinkStyle getType]), @([MentionStyle getType]) ],
+ @([CustomStyle getType]) : @[]
};
}
@@ -123,23 +124,35 @@ + (NSDictionary *)blockingMap {
@([AlignmentStyle getType]) : @[],
@([BlockQuoteStyle getType]) : @[],
@([CodeBlockStyle getType]) : @[],
- @([ImageStyle getType]) : @[ @([InlineCodeStyle getType]) ]
+ @([ImageStyle getType]) : @[ @([InlineCodeStyle getType]) ],
+ @([CustomStyle getType]) : @[]
};
}
+ (NSDictionary *)stylesDictForHost:(id)host
isInput:(BOOL)isInput {
NSArray *baseClasses = @[
- [BoldStyle class], [ItalicStyle class],
- [UnderlineStyle class], [StrikethroughStyle class],
- [InlineCodeStyle class], [LinkStyle class],
- [MentionStyle class], [H1Style class],
- [H2Style class], [H3Style class],
- [H4Style class], [H5Style class],
- [H6Style class], [UnorderedListStyle class],
- [OrderedListStyle class], [CheckboxListStyle class],
- [AlignmentStyle class], [BlockQuoteStyle class],
- [CodeBlockStyle class], [ImageStyle class]
+ [BoldStyle class],
+ [ItalicStyle class],
+ [UnderlineStyle class],
+ [StrikethroughStyle class],
+ [InlineCodeStyle class],
+ [LinkStyle class],
+ [MentionStyle class],
+ [H1Style class],
+ [H2Style class],
+ [H3Style class],
+ [H4Style class],
+ [H5Style class],
+ [H6Style class],
+ [CustomStyle class],
+ [UnorderedListStyle class],
+ [OrderedListStyle class],
+ [CheckboxListStyle class],
+ [AlignmentStyle class],
+ [BlockQuoteStyle class],
+ [CodeBlockStyle class],
+ [ImageStyle class]
];
NSArray *viewerClasses = @[
@@ -156,6 +169,7 @@ + (NSDictionary *)stylesDictForHost:(id)host
[EnrichedTextH4Style class],
[EnrichedTextH5Style class],
[EnrichedTextH6Style class],
+ [EnrichedTextCustomStyle class],
[EnrichedTextUnorderedListStyle class],
[EnrichedTextOrderedListStyle class],
[EnrichedTextCheckboxListStyle class],
diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx
index f399633c..077332ad 100644
--- a/src/native/EnrichedTextInput.tsx
+++ b/src/native/EnrichedTextInput.tsx
@@ -14,13 +14,15 @@ import EnrichedTextInputNativeComponent, {
type OnMentionDetectedInternal,
type OnRequestHtmlResultEvent,
} from '../spec/EnrichedTextInputNativeComponent';
-import type {
- HostInstance,
- MeasureInWindowOnSuccessCallback,
- MeasureLayoutOnSuccessCallback,
- MeasureOnSuccessCallback,
- NativeMethods,
- NativeSyntheticEvent,
+import {
+ processColor,
+ type ColorValue,
+ type HostInstance,
+ type MeasureInWindowOnSuccessCallback,
+ type MeasureLayoutOnSuccessCallback,
+ type MeasureOnSuccessCallback,
+ type NativeMethods,
+ type NativeSyntheticEvent,
} from 'react-native';
import { normalizeHtmlStyle } from '../utils/normalizeHtmlStyle';
import { toNativeRegexConfig } from '../utils/regexParser';
@@ -39,6 +41,19 @@ const warnMentionIndicators = (indicator: string) => {
);
};
+const getSafeColorInt = (
+ color: ColorValue | null | undefined
+): number | null => {
+ if (color == null) return null;
+
+ const processed = processColor(color);
+ if (typeof processed === 'number') {
+ return processed;
+ }
+
+ return null;
+};
+
type ComponentType = (Component & NativeMethods) | null;
type HtmlRequest = {
@@ -277,6 +292,22 @@ export const EnrichedTextInput = ({
) => {
Commands.setTextAlignment(nullthrows(nativeRef.current), alignment);
},
+ setStyle: (customStyle: {
+ foregroundColor?: ColorValue | null;
+ backgroundColor?: ColorValue | null;
+ }) => {
+ const payload: {
+ foregroundColor?: number | null;
+ backgroundColor?: number | null;
+ } = {};
+ if (customStyle.foregroundColor !== undefined) {
+ payload.foregroundColor = getSafeColorInt(customStyle.foregroundColor);
+ }
+ if (customStyle.backgroundColor !== undefined) {
+ payload.backgroundColor = getSafeColorInt(customStyle.backgroundColor);
+ }
+ Commands.setStyle(nullthrows(nativeRef.current), JSON.stringify(payload));
+ },
}));
const handleMentionEvent = (e: NativeSyntheticEvent) => {
diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts
index 20e9d12b..3467acad 100644
--- a/src/spec/EnrichedTextInputNativeComponent.ts
+++ b/src/spec/EnrichedTextInputNativeComponent.ts
@@ -124,6 +124,10 @@ export interface OnChangeStateEvent {
isBlocking: boolean;
};
alignment: string;
+ customStyle: {
+ foregroundColor: string;
+ backgroundColor: string;
+ };
}
export interface OnLinkDetected {
@@ -280,6 +284,10 @@ export interface OnContextMenuItemPressEvent {
isBlocking: boolean;
};
alignment: string;
+ customStyle: {
+ foregroundColor: string;
+ backgroundColor: string;
+ };
};
}
@@ -482,6 +490,10 @@ interface NativeCommands {
viewRef: React.ElementRef,
alignment: string
) => void;
+ setStyle: (
+ viewRef: React.ElementRef,
+ styleJSON: string
+ ) => void;
}
export const Commands: NativeCommands = codegenNativeCommands({
@@ -516,6 +528,7 @@ export const Commands: NativeCommands = codegenNativeCommands({
'addMention',
'requestHTML',
'setTextAlignment',
+ 'setStyle',
],
});
diff --git a/src/types.ts b/src/types.ts
index 0509ba64..8d1a8094 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,13 +1,13 @@
import type { RefObject } from 'react';
-import type {
- ColorValue,
- DimensionValue,
- NativeMethods,
- NativeSyntheticEvent,
- ReturnKeyTypeOptions,
- TargetedEvent,
- TextStyle,
- ViewProps,
+import {
+ type ColorValue,
+ type DimensionValue,
+ type NativeMethods,
+ type NativeSyntheticEvent,
+ type ReturnKeyTypeOptions,
+ type TargetedEvent,
+ type TextStyle,
+ type ViewProps,
} from 'react-native';
/**
@@ -380,6 +380,10 @@ export interface OnChangeStateEvent {
isBlocking: boolean;
};
alignment: string;
+ customStyle: {
+ foregroundColor: string;
+ backgroundColor: string;
+ };
}
export interface OnLinkDetected {
@@ -564,6 +568,10 @@ export interface EnrichedTextInputInstance extends NativeMethods {
setTextAlignment: (
alignment: 'left' | 'center' | 'right' | 'justify' | 'auto'
) => void;
+ setStyle: (customStyle: {
+ foregroundColor?: ColorValue | null;
+ backgroundColor?: ColorValue | null;
+ }) => void;
}
export interface ContextMenuItem {
diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx
index 06a0c264..384459cc 100644
--- a/src/web/EnrichedTextInput.tsx
+++ b/src/web/EnrichedTextInput.tsx
@@ -368,6 +368,7 @@ export const EnrichedTextInput = ({
measureLayout: () => {},
setNativeProps: () => {},
setTextAlignment: () => {},
+ setStyle: () => {},
}),
[editor]
);
diff --git a/src/web/useOnChangeState.ts b/src/web/useOnChangeState.ts
index 439f685d..6f4fcb17 100644
--- a/src/web/useOnChangeState.ts
+++ b/src/web/useOnChangeState.ts
@@ -98,6 +98,10 @@ function buildState(
isBlocking: isFormatBlocked('image', editor, htmlStyle),
},
alignment: 'left',
+ customStyle: {
+ foregroundColor: '',
+ backgroundColor: '',
+ },
};
}