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"), + "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: '', + }, }; }