Skip to content

Commit 8016f73

Browse files
feat(iOS): add custom style
1 parent 4749460 commit 8016f73

23 files changed

Lines changed: 681 additions & 43 deletions

apps/example/src/constants/editorConfig.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export const DEFAULT_STYLES: StylesState = {
3333
mention: DEFAULT_STYLE_STATE,
3434
checkboxList: DEFAULT_STYLE_STATE,
3535
alignment: 'left',
36+
customStyle: {
37+
foregroundColor: '',
38+
backgroundColor: '',
39+
},
3640
};
3741

3842
export const DEFAULT_LINK_STATE = {

ios/EnrichedTextInputView.mm

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#import "EnrichedTextInputView.h"
22
#import "AlignmentUtils.h"
33
#import "AttachmentLayoutUtils.h"
4+
#import "ColorExtension.h"
45
#import "CoreText/CoreText.h"
56
#import "DotReplacementUtils.h"
67
#import "HtmlParser.h"
@@ -17,6 +18,7 @@
1718
#import "WordsUtils.h"
1819
#import "ZeroWidthSpaceUtils.h"
1920
#import <React/RCTConversions.h>
21+
#import <React/RCTConvert.h>
2022
#import <ReactNativeEnriched/EnrichedTextInputViewComponentDescriptor.h>
2123
#import <ReactNativeEnriched/EventEmitters.h>
2224
#import <ReactNativeEnriched/Props.h>
@@ -60,6 +62,7 @@ @implementation EnrichedTextInputView {
6062
NSString *_submitBehavior;
6163
NSDictionary<NSAttributedStringKey, id> *_capturedAttributesBeforeChange;
6264
NSString *_recentlyEmittedAlignment;
65+
CustomStyleData *_recentlyEmittedCustomStyle;
6366
}
6467

6568
@synthesize blockEmitting = blockEmitting;
@@ -1092,13 +1095,23 @@ - (void)tryUpdatingActiveStyles {
10921095
updateNeeded = YES;
10931096
}
10941097

1098+
// detect custom style change
1099+
CustomStyle *customStyle = stylesDict[@([CustomStyle getType])];
1100+
CustomStyleData *currentCustomStyle =
1101+
[customStyle getCustomStyleDataAt:textView.selectedRange.location];
1102+
if (currentCustomStyle != _recentlyEmittedCustomStyle &&
1103+
![currentCustomStyle isEqual:_recentlyEmittedCustomStyle]) {
1104+
updateNeeded = YES;
1105+
}
1106+
10951107
if (updateNeeded) {
10961108
auto emitter = [self getEventEmitter];
10971109
if (emitter != nullptr) {
10981110
// update activeStyles and blockedStyles only if emitter is available
10991111
_activeStyles = newActiveStyles;
11001112
_blockedStyles = newBlockedStyles;
11011113
_recentlyEmittedAlignment = currentAlignment;
1114+
_recentlyEmittedCustomStyle = currentCustomStyle;
11021115

11031116
emitter->onChangeState(
11041117
{.bold = GET_STYLE_STATE([BoldStyle getType]),
@@ -1120,7 +1133,14 @@ - (void)tryUpdatingActiveStyles {
11201133
.codeBlock = GET_STYLE_STATE([CodeBlockStyle getType]),
11211134
.image = GET_STYLE_STATE([ImageStyle getType]),
11221135
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]),
1123-
.alignment = [currentAlignment UTF8String]});
1136+
.alignment = [currentAlignment UTF8String],
1137+
.customStyle = {
1138+
.foregroundColor =
1139+
[[currentCustomStyle.foregroundColor rgbaString] UTF8String]
1140+
?: "",
1141+
.backgroundColor =
1142+
[[currentCustomStyle.backgroundColor rgbaString] UTF8String]
1143+
?: ""}});
11241144
}
11251145
}
11261146

@@ -1268,6 +1288,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args {
12681288
if (!_placeholderLabel.isHidden) {
12691289
[self refreshPlaceholderLabelStyles];
12701290
}
1291+
} else if ([commandName isEqualToString:@"setStyle"]) {
1292+
NSString *styleJSON = (NSString *)args[0];
1293+
[self setStyle:styleJSON];
12711294
}
12721295
}
12731296

@@ -1475,6 +1498,52 @@ - (void)toggleRegularStyle:(StyleType)type {
14751498
}
14761499
}
14771500

1501+
- (void)setStyle:(NSString *)styleJSON {
1502+
NSData *jsonData = [styleJSON dataUsingEncoding:NSUTF8StringEncoding];
1503+
if (jsonData == nil)
1504+
return;
1505+
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:jsonData
1506+
options:0
1507+
error:nil];
1508+
if (dict == nil)
1509+
return;
1510+
1511+
NSRange selectedRange = textView.selectedRange;
1512+
CustomStyle *customStyleClass =
1513+
(CustomStyle *)stylesDict[@([CustomStyle getType])];
1514+
if (customStyleClass == nil)
1515+
return;
1516+
1517+
if (![StyleUtils handleStyleBlocksAndConflicts:[CustomStyle getType]
1518+
range:selectedRange
1519+
forHost:self]) {
1520+
return;
1521+
}
1522+
1523+
// Convert raw JSON values (NSNumber ARGB integers from processColor) to
1524+
// UIColor. NSNull is passed through as-is so mergeFromDict: can clear
1525+
// the color when the caller explicitly passes null.
1526+
NSMutableDictionary *processedDict = [NSMutableDictionary new];
1527+
1528+
id fgRaw = dict[@"foregroundColor"];
1529+
if (fgRaw != nil) {
1530+
processedDict[@"foregroundColor"] = [fgRaw isKindOfClass:[NSNull class]]
1531+
? [NSNull null]
1532+
: [RCTConvert UIColor:fgRaw];
1533+
}
1534+
1535+
id bgRaw = dict[@"backgroundColor"];
1536+
if (bgRaw != nil) {
1537+
processedDict[@"backgroundColor"] = [bgRaw isKindOfClass:[NSNull class]]
1538+
? [NSNull null]
1539+
: [RCTConvert UIColor:bgRaw];
1540+
}
1541+
1542+
[customStyleClass applyStyleFromDict:processedDict
1543+
selectedRange:selectedRange];
1544+
[self anyTextMayHaveBeenModified];
1545+
}
1546+
14781547
- (void)toggleCheckboxList:(BOOL)checked {
14791548
CheckboxListStyle *style =
14801549
(CheckboxListStyle *)stylesDict[@([CheckboxListStyle getType])];
@@ -1827,6 +1896,10 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText {
18271896
AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])];
18281897
NSString *currentAlignment = [alignmentStyle getStyleState];
18291898

1899+
CustomStyle *customStyle = stylesDict[@([CustomStyle getType])];
1900+
CustomStyleData *contextCustomStyleData =
1901+
[customStyle getCustomStyleDataAt:textView.selectedRange.location];
1902+
18301903
emitter->onContextMenuItemPress(
18311904
{.itemText = [itemText toCppString],
18321905
.selectedText = [selectedText toCppString],
@@ -1853,7 +1926,16 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText {
18531926
.image = GET_STYLE_STATE([ImageStyle getType]),
18541927
.mention = GET_STYLE_STATE([MentionStyle getType]),
18551928
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]),
1856-
.alignment = [currentAlignment UTF8String]}});
1929+
.alignment = [currentAlignment UTF8String],
1930+
.customStyle = {
1931+
.foregroundColor =
1932+
[[contextCustomStyleData.foregroundColor rgbaString]
1933+
UTF8String]
1934+
?: "",
1935+
.backgroundColor =
1936+
[[contextCustomStyleData.backgroundColor rgbaString]
1937+
UTF8String]
1938+
?: ""}}});
18571939
}
18581940
}
18591941

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#pragma once
2+
#import <UIKit/UIKit.h>
3+
4+
@interface CustomStyleData : NSObject <NSCopying>
5+
6+
@property(nonatomic, strong, nullable) UIColor *foregroundColor;
7+
@property(nonatomic, strong, nullable) UIColor *backgroundColor;
8+
9+
- (BOOL)isEmpty;
10+
11+
// Applies a partial update from a dict (keys: "foregroundColor",
12+
// "backgroundColor"). A key absent from the dict leaves the field unchanged;
13+
// NSNull or any non-UIColor value clears it; a UIColor value sets it.
14+
- (void)mergeFromDict:(NSDictionary *)dict;
15+
16+
@end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#import "CustomStyleData.h"
2+
3+
@implementation CustomStyleData
4+
5+
- (BOOL)isEmpty {
6+
return _foregroundColor == nil && _backgroundColor == nil;
7+
}
8+
9+
- (void)mergeFromDict:(NSDictionary *)dict {
10+
id fgVal = dict[@"foregroundColor"];
11+
if (fgVal != nil) {
12+
self.foregroundColor =
13+
[fgVal isKindOfClass:[UIColor class]] ? (UIColor *)fgVal : nil;
14+
}
15+
id bgVal = dict[@"backgroundColor"];
16+
if (bgVal != nil) {
17+
self.backgroundColor =
18+
[bgVal isKindOfClass:[UIColor class]] ? (UIColor *)bgVal : nil;
19+
}
20+
}
21+
22+
- (BOOL)isEqual:(id)object {
23+
if (self == object)
24+
return YES;
25+
if (![object isKindOfClass:[CustomStyleData class]])
26+
return NO;
27+
CustomStyleData *other = (CustomStyleData *)object;
28+
BOOL fgEqual = (_foregroundColor == other.foregroundColor) ||
29+
[_foregroundColor isEqual:other.foregroundColor];
30+
BOOL bgEqual = (_backgroundColor == other.backgroundColor) ||
31+
[_backgroundColor isEqual:other.backgroundColor];
32+
return fgEqual && bgEqual;
33+
}
34+
35+
- (NSUInteger)hash {
36+
return [_foregroundColor hash] ^ [_backgroundColor hash];
37+
}
38+
39+
- (id)copyWithZone:(NSZone *)zone {
40+
CustomStyleData *copy = [[CustomStyleData allocWithZone:zone] init];
41+
copy.foregroundColor = self.foregroundColor;
42+
copy.backgroundColor = self.backgroundColor;
43+
return copy;
44+
}
45+
46+
@end

ios/extensions/ColorExtension.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@
44
@interface UIColor (ColorExtension)
55
- (BOOL)isEqualToColor:(UIColor *)otherColor;
66
- (UIColor *)colorWithAlphaIfNotTransparent:(CGFloat)newAlpha;
7+
- (NSString *)rgbaString;
8+
/// Parses a CSS rgba() string, e.g. @"rgba(255, 0, 0, 1.00)". Returns nil if
9+
/// the string is not a valid rgba() value.
10+
+ (UIColor *_Nullable)colorFromRgbaString:(NSString *_Nullable)rgba;
711
@end

ios/extensions/ColorExtension.mm

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,60 @@ - (UIColor *)colorWithAlphaIfNotTransparent:(CGFloat)newAlpha {
3535
}
3636
return self;
3737
}
38+
39+
- (NSString *)rgbaString {
40+
CGFloat red = 0.0;
41+
CGFloat green = 0.0;
42+
CGFloat blue = 0.0;
43+
CGFloat alpha = 0.0;
44+
45+
// getRed:green:blue:alpha: returns YES if the color can be converted to RGB.
46+
// It natively handles monochrome/grayscale colors as well.
47+
if ([self getRed:&red green:&green blue:&blue alpha:&alpha]) {
48+
// Convert 0.0-1.0 floats to 0-255 integers for RGB
49+
int r = (int)round(red * 255.0);
50+
int g = (int)round(green * 255.0);
51+
int b = (int)round(blue * 255.0);
52+
53+
return
54+
[NSString stringWithFormat:@"rgba(%d, %d, %d, %.2f)", r, g, b, alpha];
55+
}
56+
57+
// Fallback for unsupported color
58+
return @"";
59+
}
60+
61+
+ (UIColor *)colorFromRgbaString:(NSString *)rgba {
62+
if (rgba.length == 0)
63+
return nil;
64+
65+
static NSRegularExpression *regex;
66+
static dispatch_once_t onceToken;
67+
dispatch_once(&onceToken, ^{
68+
regex = [NSRegularExpression
69+
regularExpressionWithPattern:
70+
@"rgba\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,"
71+
@"\\s*([\\d.]+)\\s*\\)"
72+
options:NSRegularExpressionCaseInsensitive
73+
error:nil];
74+
});
75+
76+
NSTextCheckingResult *match =
77+
[regex firstMatchInString:rgba
78+
options:0
79+
range:NSMakeRange(0, rgba.length)];
80+
if (!match || match.numberOfRanges < 5)
81+
return nil;
82+
83+
CGFloat r =
84+
[[rgba substringWithRange:[match rangeAtIndex:1]] integerValue] / 255.0;
85+
CGFloat g =
86+
[[rgba substringWithRange:[match rangeAtIndex:2]] integerValue] / 255.0;
87+
CGFloat b =
88+
[[rgba substringWithRange:[match rangeAtIndex:3]] integerValue] / 255.0;
89+
CGFloat a = [[rgba substringWithRange:[match rangeAtIndex:4]] doubleValue];
90+
91+
return [UIColor colorWithRed:r green:g blue:b alpha:a];
92+
}
93+
3894
@end

0 commit comments

Comments
 (0)