Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8016f73
feat(iOS): add custom style
kacperzolkiewski Jun 16, 2026
fb8cdc0
fix: copilot code review
kacperzolkiewski Jun 16, 2026
e4f4804
fix: reapplying attributes
kacperzolkiewski Jun 16, 2026
2c29d83
fix: use hex for colors
kacperzolkiewski Jun 17, 2026
c2c0613
feat: handle named colors in html parsing
kacperzolkiewski Jun 17, 2026
ce12b57
feat: add colors to toolbar
kacperzolkiewski Jun 17, 2026
18ca4bd
Merge branch 'main' into @kacperzolkiewski/feat-custom-style
kacperzolkiewski Jun 17, 2026
ec7521b
test: add e2e test for colors
kacperzolkiewski Jun 17, 2026
9c0c095
fix: handling rgb colors
kacperzolkiewski Jun 17, 2026
f34fbf0
Merge branch 'main' into @kacperzolkiewski/feat-custom-style
kacperzolkiewski Jun 19, 2026
cf3739e
fix: custom style parsing
kacperzolkiewski Jun 22, 2026
4ab7531
fix: align normalizer to properly handle span with colors
kacperzolkiewski Jun 22, 2026
0e37c0d
fix: handle opacity in custom style colors
kacperzolkiewski Jul 29, 2026
e29cdfa
fix: foreground color
kacperzolkiewski Jul 31, 2026
1c1933b
Merge branch '@kacperzolkiewski/custom-style' into @kacperzolkiewski/…
kacperzolkiewski Aug 7, 2026
1d2aa4a
fix: update podfile
kacperzolkiewski Aug 7, 2026
eeaa4ac
fix: applying custom style in renderer
kacperzolkiewski Aug 7, 2026
595ba1d
Merge branch '@kacperzolkiewski/feat-custom-style' of github.com:soft…
kacperzolkiewski Aug 7, 2026
dc48a57
test: update screenshots
kacperzolkiewski Aug 7, 2026
f1c7a1b
fix: update font screenshots
kacperzolkiewski Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/example/src/constants/editorConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export const DEFAULT_STYLES: StylesState = {
mention: DEFAULT_STYLE_STATE,
checkboxList: DEFAULT_STYLE_STATE,
alignment: 'left',
customStyle: {
foregroundColor: '',
backgroundColor: '',
},
};

export const DEFAULT_LINK_STATE = {
Expand Down
86 changes: 84 additions & 2 deletions ios/EnrichedTextInputView.mm
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -17,6 +18,7 @@
#import "WordsUtils.h"
#import "ZeroWidthSpaceUtils.h"
#import <React/RCTConversions.h>
#import <React/RCTConvert.h>
#import <ReactNativeEnriched/EnrichedTextInputViewComponentDescriptor.h>
#import <ReactNativeEnriched/EventEmitters.h>
#import <ReactNativeEnriched/Props.h>
Expand Down Expand Up @@ -60,6 +62,7 @@ @implementation EnrichedTextInputView {
NSString *_submitBehavior;
NSDictionary<NSAttributedStringKey, id> *_capturedAttributesBeforeChange;
NSString *_recentlyEmittedAlignment;
CustomStyleData *_recentlyEmittedCustomStyle;
}

@synthesize blockEmitting = blockEmitting;
Expand Down Expand Up @@ -1092,13 +1095,23 @@ - (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) {
// update activeStyles and blockedStyles only if emitter is available
_activeStyles = newActiveStyles;
_blockedStyles = newBlockedStyles;
_recentlyEmittedAlignment = currentAlignment;
_recentlyEmittedCustomStyle = currentCustomStyle;

emitter->onChangeState(
{.bold = GET_STYLE_STATE([BoldStyle getType]),
Expand All @@ -1120,7 +1133,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 rgbaString] UTF8String]
?: "",
.backgroundColor =
[[currentCustomStyle.backgroundColor rgbaString] UTF8String]
?: ""}});
}
}

Expand Down Expand Up @@ -1268,6 +1288,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];
}
}

Expand Down Expand Up @@ -1475,6 +1498,52 @@ - (void)toggleRegularStyle:(StyleType)type {
}
}

- (void)setStyle:(NSString *)styleJSON {
NSData *jsonData = [styleJSON dataUsingEncoding:NSUTF8StringEncoding];
if (jsonData == nil)
return;
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:jsonData
options:0
error:nil];
if (dict == nil)
return;
Comment thread
kacperzolkiewski marked this conversation as resolved.
Outdated

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])];
Expand Down Expand Up @@ -1827,6 +1896,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],
Expand All @@ -1853,7 +1926,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 rgbaString]
UTF8String]
?: "",
.backgroundColor =
[[contextCustomStyleData.backgroundColor rgbaString]
UTF8String]
?: ""}}});
}
}

Expand Down
16 changes: 16 additions & 0 deletions ios/customStyleData/CustomStyleData.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#pragma once
#import <UIKit/UIKit.h>

@interface CustomStyleData : NSObject <NSCopying>

@property(nonatomic, strong, nullable) UIColor *foregroundColor;
@property(nonatomic, strong, nullable) UIColor *backgroundColor;

- (BOOL)isEmpty;

// Applies a partial update from a dict (keys: "foregroundColor",
// "backgroundColor"). A key absent from the dict leaves the field unchanged;
// NSNull or any non-UIColor value clears it; a UIColor value sets it.
- (void)mergeFromDict:(NSDictionary *)dict;

@end
46 changes: 46 additions & 0 deletions ios/customStyleData/CustomStyleData.mm
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions ios/extensions/ColorExtension.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@
@interface UIColor (ColorExtension)
- (BOOL)isEqualToColor:(UIColor *)otherColor;
- (UIColor *)colorWithAlphaIfNotTransparent:(CGFloat)newAlpha;
- (NSString *)rgbaString;
/// Parses a CSS rgba() string, e.g. @"rgba(255, 0, 0, 1.00)". Returns nil if
/// the string is not a valid rgba() value.
+ (UIColor *_Nullable)colorFromRgbaString:(NSString *_Nullable)rgba;
@end
56 changes: 56 additions & 0 deletions ios/extensions/ColorExtension.mm
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,60 @@ - (UIColor *)colorWithAlphaIfNotTransparent:(CGFloat)newAlpha {
}
return self;
}

- (NSString *)rgbaString {
CGFloat red = 0.0;
CGFloat green = 0.0;
CGFloat blue = 0.0;
CGFloat alpha = 0.0;

// getRed:green:blue:alpha: returns YES if the color can be converted to RGB.
// It natively handles monochrome/grayscale colors as well.
if ([self getRed:&red green:&green blue:&blue alpha:&alpha]) {
// Convert 0.0-1.0 floats to 0-255 integers for RGB
int r = (int)round(red * 255.0);
int g = (int)round(green * 255.0);
int b = (int)round(blue * 255.0);
Comment thread
Copilot marked this conversation as resolved.
Outdated

return
[NSString stringWithFormat:@"rgba(%d, %d, %d, %.2f)", r, g, b, alpha];
}

// Fallback for unsupported color
return @"";
}

+ (UIColor *)colorFromRgbaString:(NSString *)rgba {
Comment thread
Copilot marked this conversation as resolved.
Outdated
if (rgba.length == 0)
return nil;

static NSRegularExpression *regex;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
regex = [NSRegularExpression
regularExpressionWithPattern:
@"rgba\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,"
@"\\s*([\\d.]+)\\s*\\)"
options:NSRegularExpressionCaseInsensitive
error:nil];
});

NSTextCheckingResult *match =
[regex firstMatchInString:rgba
options:0
range:NSMakeRange(0, rgba.length)];
if (!match || match.numberOfRanges < 5)
return nil;

CGFloat r =
[[rgba substringWithRange:[match rangeAtIndex:1]] integerValue] / 255.0;
CGFloat g =
[[rgba substringWithRange:[match rangeAtIndex:2]] integerValue] / 255.0;
CGFloat b =
[[rgba substringWithRange:[match rangeAtIndex:3]] integerValue] / 255.0;
CGFloat a = [[rgba substringWithRange:[match rangeAtIndex:4]] doubleValue];

return [UIColor colorWithRed:r green:g blue:b alpha:a];
}

@end
Loading