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 000000000..04ad337d1
--- /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 000000000..5db13c784
Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/custom_style_colors.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 7f0cfabe9..7085daf9e 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 a32731ece..9859c8102 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 b0c1a44ca..b2f8fc378 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_colors_visual.yaml b/.maestro/enrichedText/flows/custom_style_colors_visual.yaml
new file mode 100644
index 000000000..e304b5b94
--- /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 000000000..e80d2d38d
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 9dcbb8244..3cb4a5e86 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 8f4a663de..520b10372 100644
--- a/apps/example/ios/Podfile.lock
+++ b/apps/example/ios/Podfile.lock
@@ -2108,7 +2108,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d
- hermes-engine: 78997c9592858f3aeaacad4f0062cd700ba8dda2
+ hermes-engine: 03c4f027614fa33388949bb373cbf7a4892b8e5f
RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b
RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0
RCTSwiftUI: 5aaf0b07e747ba749dc6acc94d8bd41eea4b570f
diff --git a/apps/example/src/components/ColorPickerRow.tsx b/apps/example/src/components/ColorPickerRow.tsx
new file mode 100644
index 000000000..ad428cb11
--- /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 20109e390..c44d4d5d8 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 2b9d44b0b..092911f9e 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 5a6210f15..2ccbcd885 100644
--- a/cpp/parser/GumboNormalizer.c
+++ b/cpp/parser/GumboNormalizer.c
@@ -852,9 +852,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 ff06232c9..fefbe99e9 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 a899aa629..5e6123cd5 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])];
@@ -1867,6 +1937,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],
@@ -1893,7 +1967,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]
+ ?: ""}}});
}
}
@@ -1920,8 +2003,9 @@ - (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. Only capture for genuine replacements (text.length > 0), not
- // for deletions/backspace (text.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
diff --git a/ios/customStyleData/CustomStyleData.h b/ios/customStyleData/CustomStyleData.h
new file mode 100644
index 000000000..7ee405f8e
--- /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 000000000..c08f1fd54
--- /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 c3a2323c9..33550251b 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 32877787f..8c1f7ff9a 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 1b8d3fd32..c6b778a73 100644
--- a/ios/htmlParser/HtmlParser.mm
+++ b/ios/htmlParser/HtmlParser.mm
@@ -1,7 +1,10 @@
#import "HtmlParser.h"
#import "AlignmentEntry.h"
#import "AlignmentUtils.h"
+#import "ColorExtension.h"
+#import "CustomStyleData.h"
#import "EnrichedConfig.h"
+#include "GumboParser.hpp"
#import "ImageData.h"
#import "LinkData.h"
#import "MentionParams.h"
@@ -9,8 +12,6 @@
#import "StyleHeaders.h"
#import "StylePair.h"
-#include "GumboParser.hpp"
-
@implementation HtmlParser
+ (BOOL)isBlockTag:(NSString *)tagName {
@@ -42,9 +43,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];
@@ -834,9 +836,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;
}
@@ -866,6 +874,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);
@@ -1005,6 +1014,7 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range
// clear the previous styles
previousActiveStyles = [[NSSet alloc] init];
+ lastCustomStyleData = nil;
// next character opens new paragraph
newLine = YES;
@@ -1166,6 +1176,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
@@ -1211,6 +1251,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;
}
@@ -1431,6 +1476,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 @"";
}
@@ -1455,6 +1528,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 fc07d871f..02b953eae 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 05c331b75..6ab816209 100644
--- a/ios/inputHtmlParser/InputHtmlParser.mm
+++ b/ios/inputHtmlParser/InputHtmlParser.mm
@@ -182,6 +182,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 008f0fce4..6d1a73af8 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 41a3d27de..5075c5513 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 f43c53224..1c0fac945 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 aca5d4a3c..322a7df5a 100644
--- a/ios/interfaces/StyleHeaders.h
+++ b/ios/interfaces/StyleHeaders.h
@@ -1,4 +1,5 @@
#pragma once
+#import "CustomStyleData.h"
#import "ImageData.h"
#import "LinkData.h"
#import "MentionParams.h"
@@ -6,6 +7,16 @@
@class EnrichedConfig;
+@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 701d79eaf..a5c163fc8 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 000000000..b79f767db
--- /dev/null
+++ b/ios/styles/CustomStyle.mm
@@ -0,0 +1,211 @@
+#import "ColorExtension.h"
+#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 colorWithResolvedAlpha];
+ }
+ 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 fde9abfac..f125f66dc 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 3899fd2d8..b3d4acda6 100644
--- a/ios/textHtmlParser/TextHtmlParser.mm
+++ b/ios/textHtmlParser/TextHtmlParser.mm
@@ -60,100 +60,143 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles {
// shift subsequent ranges by this offset.
NSInteger zeroWidthSpaceOffset = 0;
- // Inline styles collected during the first pass so their applyStyling: can
- // be re-run after all paragraph styles have applied their visual attributes.
- // Each entry is @[style, adjustedRange].
+ // Custom and inline styles collected during the first pass so applyStyling:
+ // runs after paragraph visual attributes are in place. Custom (priority 1)
+ // is applied before inline (priority 2). Each entry is @[style,
+ // adjustedRange].
+ NSMutableArray *pendingCustomApply = [NSMutableArray array];
NSMutableArray *pendingInlineApply = [NSMutableArray array];
- // Paragraph styles call applyStyling: immediately; inline styles
- // defer it so that paragraph visual attributes are already in
- // place when inline styles override them.
+ NSMutableArray *paragraphStyles = [NSMutableArray array];
+ NSMutableArray *nonParagraphStyles = [NSMutableArray array];
for (NSArray *arr in processedStyles) {
- NSNumber *styleType = (NSNumber *)arr[0];
- StylePair *stylePair = (StylePair *)arr[1];
- StyleBase *style = _view->stylesDict[styleType];
+ StyleBase *style = _view->stylesDict[arr[0]];
if (style == nullptr)
continue;
-
- NSRange parsedRange = [stylePair.rangeValue rangeValue];
- NSUInteger textLengthBeforeStyleApplied =
- _view->textView.textStorage.string.length;
-
- // Range must be taking zeroWidthSpaceOffset into consideration
- // because processed styles ranges are relative to only the new text while
- // we need absolute ranges relative to the whole existing text
- NSRange styleRange = NSMakeRange(
- zeroWidthSpaceOffset + parsedRange.location, parsedRange.length);
-
- if (![StyleUtils handleStyleBlocksAndConflicts:[[style class] getType]
- range:styleRange
- forHost:_view]) {
- continue;
+ if ([style isParagraph]) {
+ [paragraphStyles addObject:arr];
+ } else {
+ [nonParagraphStyles addObject:arr];
}
+ }
- if ([styleType isEqualToNumber:@([LinkStyle getType])]) {
- LinkData *linkData = (LinkData *)stylePair.styleValue;
- [((LinkStyle *)style) applyLinkMetaWithData:linkData range:styleRange];
- } else if ([styleType isEqualToNumber:@([MentionStyle getType])]) {
- MentionParams *params = (MentionParams *)stylePair.styleValue;
- [((MentionStyle *)style) applyMentionMeta:params range:styleRange];
- } else if ([styleType isEqualToNumber:@([ImageStyle getType])]) {
- ImageData *imgData = (ImageData *)stylePair.styleValue;
- [((ImageStyle *)style) addImageAtRange:styleRange
- imageData:imgData
- withSelection:NO
- withDirtyRange:NO];
- } else if ([styleType isEqualToNumber:@([CheckboxListStyle getType])]) {
- NSDictionary *checkboxStates = (NSDictionary *)stylePair.styleValue;
- CheckboxListStyle *cbStyle = (CheckboxListStyle *)style;
-
- [cbStyle addWithChecked:NO
- range:styleRange
- withTyping:NO
- withDirtyRange:NO];
-
- if (checkboxStates && checkboxStates.count > 0) {
- for (NSNumber *key in checkboxStates) {
- NSUInteger checkboxPosition =
- zeroWidthSpaceOffset + [key unsignedIntegerValue];
- BOOL isChecked = [checkboxStates[key] boolValue];
-
- if (isChecked) {
- [cbStyle toggleCheckedAt:checkboxPosition withDirtyRange:NO];
- }
+ // HtmlParser emits close-tag order (inner before outer). Apply paragraph
+ // styles first so ZWS insertions happen before inline meta is written.
+ [paragraphStyles
+ sortUsingComparator:^NSComparisonResult(NSArray *a, NSArray *b) {
+ NSRange rangeA = [(StylePair *)a[1] rangeValue].rangeValue;
+ NSRange rangeB = [(StylePair *)b[1] rangeValue].rangeValue;
+ if (rangeA.location != rangeB.location) {
+ return rangeA.location < rangeB.location ? NSOrderedAscending
+ : NSOrderedDescending;
}
+ return NSOrderedSame;
+ }];
+
+ for (NSArray *styleGroup in @[ paragraphStyles, nonParagraphStyles ]) {
+ BOOL isParagraphPass = styleGroup == paragraphStyles;
+
+ for (NSArray *arr in styleGroup) {
+ NSNumber *styleType = (NSNumber *)arr[0];
+ StylePair *stylePair = (StylePair *)arr[1];
+ StyleBase *style = _view->stylesDict[styleType];
+ if (style == nullptr)
+ continue;
+
+ NSRange parsedRange = [stylePair.rangeValue rangeValue];
+ NSUInteger textLengthBeforeStyleApplied =
+ _view->textView.textStorage.string.length;
+
+ // Range must be taking zeroWidthSpaceOffset into consideration
+ // because processed styles ranges are relative to only the new text while
+ // we need absolute ranges relative to the whole existing text
+ NSRange styleRange = NSMakeRange(
+ zeroWidthSpaceOffset + parsedRange.location, parsedRange.length);
+
+ if (![StyleUtils handleStyleBlocksAndConflicts:[[style class] getType]
+ range:styleRange
+ forHost:_view]) {
+ continue;
}
- } else {
- [style add:styleRange withTyping:NO withDirtyRange:NO];
- }
-
- [ZeroWidthSpaceUtils addSpacesIfNeededInHost:_view inRange:styleRange];
-
- NSInteger delta = _view->textView.textStorage.string.length -
- textLengthBeforeStyleApplied;
- // Use an adjusted range so that applyStyling covers any ZWS characters that
- // were just inserted by addSpacesIfNeededInHost:inRange:. Without this, a
- // style applied to an empty range {0,0} would call applyStyling on {0,0}
- // even after a ZWS was inserted.
- NSRange adjustedStyleRange = NSMakeRange(
- styleRange.location, styleRange.length + (NSUInteger)MAX(0LL, delta));
+ if ([styleType isEqualToNumber:@([LinkStyle getType])]) {
+ LinkData *linkData = (LinkData *)stylePair.styleValue;
+ [((LinkStyle *)style) applyLinkMetaWithData:linkData range:styleRange];
+ } else if ([styleType isEqualToNumber:@([MentionStyle getType])]) {
+ MentionParams *params = (MentionParams *)stylePair.styleValue;
+ [((MentionStyle *)style) applyMentionMeta:params range:styleRange];
+ } else if ([styleType isEqualToNumber:@([ImageStyle getType])]) {
+ ImageData *imgData = (ImageData *)stylePair.styleValue;
+ [((ImageStyle *)style) addImageAtRange:styleRange
+ imageData:imgData
+ withSelection:NO
+ withDirtyRange:NO];
+ } else if ([styleType isEqualToNumber:@([CheckboxListStyle getType])]) {
+ NSDictionary *checkboxStates = (NSDictionary *)stylePair.styleValue;
+ CheckboxListStyle *cbStyle = (CheckboxListStyle *)style;
+
+ [cbStyle addWithChecked:NO
+ range:styleRange
+ withTyping:NO
+ withDirtyRange:NO];
+
+ if (checkboxStates && checkboxStates.count > 0) {
+ for (NSNumber *key in checkboxStates) {
+ NSUInteger checkboxPosition =
+ zeroWidthSpaceOffset + [key unsignedIntegerValue];
+ BOOL isChecked = [checkboxStates[key] boolValue];
+
+ if (isChecked) {
+ [cbStyle toggleCheckedAt:checkboxPosition withDirtyRange:NO];
+ }
+ }
+ }
+ } 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];
+ }
- if ([style isParagraph]) {
- [style applyStyling:adjustedStyleRange];
- } else {
- [pendingInlineApply
- addObject:@[ style, [NSValue valueWithRange:adjustedStyleRange] ]];
- }
+ [ZeroWidthSpaceUtils addSpacesIfNeededInHost:_view inRange:styleRange];
+
+ NSInteger delta = _view->textView.textStorage.string.length -
+ textLengthBeforeStyleApplied;
+
+ // Use an adjusted range so that applyStyling covers any ZWS characters
+ // that were just inserted by addSpacesIfNeededInHost:inRange:. Without
+ // this, a style applied to an empty range {0,0} would call applyStyling
+ // on {0,0} even after a ZWS was inserted.
+ NSRange adjustedStyleRange = NSMakeRange(
+ styleRange.location, styleRange.length + (NSUInteger)MAX(0LL, delta));
+
+ if (isParagraphPass) {
+ [style applyStyling:adjustedStyleRange];
+ } else if ([style stylePriority] == 1) {
+ [pendingCustomApply
+ addObject:@[ style, [NSValue valueWithRange:adjustedStyleRange] ]];
+ } else {
+ [pendingInlineApply
+ addObject:@[ style, [NSValue valueWithRange:adjustedStyleRange] ]];
+ }
- // Image shifts are already handled by _precedingImageCount during tag
- // finalization.
- if (delta != 0 && ![styleType isEqualToNumber:@([ImageStyle getType])]) {
- zeroWidthSpaceOffset += delta;
+ // Image shifts are already handled by _precedingImageCount during tag
+ // finalization.
+ if (delta != 0 && ![styleType isEqualToNumber:@([ImageStyle getType])]) {
+ zeroWidthSpaceOffset += delta;
+ }
}
}
- // Apply visual styling for inline styles
+ // Apply visual styling for custom styles, then inline styles.
+ for (NSArray *entry in pendingCustomApply) {
+ StyleBase *style = entry[0];
+ NSRange adjustedStyleRange = [((NSValue *)entry[1]) rangeValue];
+ [style applyStyling:adjustedStyleRange];
+ }
for (NSArray *entry in pendingInlineApply) {
StyleBase *style = entry[0];
NSRange adjustedStyleRange = [((NSValue *)entry[1]) rangeValue];
diff --git a/ios/utils/StyleUtils.mm b/ios/utils/StyleUtils.mm
index 838269221..a3ef1b178 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 feffab4cc..0b102b700 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 {
- HostComponent,
- HostInstance,
- MeasureInWindowOnSuccessCallback,
- MeasureLayoutOnSuccessCallback,
- MeasureOnSuccessCallback,
- NativeSyntheticEvent,
+import {
+ processColor,
+ type ColorValue,
+ type HostComponent,
+ type HostInstance,
+ type MeasureInWindowOnSuccessCallback,
+ type MeasureLayoutOnSuccessCallback,
+ type MeasureOnSuccessCallback,
+ 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 = ComponentRef>;
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 20e9d12b4..3467acadb 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 d4d0dfd8b..c9c8034c6 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 692dec9e7..32d1a2121 100644
--- a/src/web/EnrichedTextInput.tsx
+++ b/src/web/EnrichedTextInput.tsx
@@ -413,6 +413,7 @@ export const EnrichedTextInput = ({
runFocused(editor, (c) => c.setTextAlign(alignment));
}
},
+ setStyle: () => {},
}),
[editor, mentionIndicatorsRef, useHtmlNormalizerRef, sanitizationConfigRef]
);
diff --git a/src/web/useOnChangeState.ts b/src/web/useOnChangeState.ts
index 3d36dc8ee..3c51b0f1a 100644
--- a/src/web/useOnChangeState.ts
+++ b/src/web/useOnChangeState.ts
@@ -99,6 +99,10 @@ function buildState(
isBlocking: isFormatBlocked('image', editor, htmlStyle),
},
alignment: getCurrentAlignment(editor) ?? 'auto',
+ customStyle: {
+ foregroundColor: '',
+ backgroundColor: '',
+ },
};
}