clintplay-react-native 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/android/src/main/java/digital/clint/clintplay/ClintplayRNModule.kt +49 -1
- package/dist/index.d.mts +158 -6
- package/dist/index.d.ts +158 -6
- package/dist/index.js +89 -45
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +89 -46
- package/dist/index.mjs.map +1 -1
- package/ios/ClintplayRN.mm +112 -6
- package/package.json +1 -1
package/ios/ClintplayRN.mm
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
#import <React/RCTBridgeModule.h>
|
|
12
12
|
#import <UIKit/UIKit.h>
|
|
13
13
|
|
|
14
|
+
@interface UIView (ClintplayNativeID)
|
|
15
|
+
- (NSString *)nativeID;
|
|
16
|
+
- (NSString *)nativeId;
|
|
17
|
+
@end
|
|
18
|
+
|
|
14
19
|
static UIWindow *FindKeyWindow(void) {
|
|
15
20
|
if (@available(iOS 13.0, *)) {
|
|
16
21
|
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
|
|
@@ -121,6 +126,73 @@ static NSString *HexColorStringFromUIColor(UIColor *color) {
|
|
|
121
126
|
return nil;
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
// The React Native <Text> is not a UILabel: it is RCTParagraphComponentView on the New
|
|
130
|
+
// Architecture and RCTTextView on the legacy one. Both keep the rendered string with its
|
|
131
|
+
// attributes; reading it here gives the replay the visible text plus size, weight, colour
|
|
132
|
+
// and alignment (accessibilityLabel carries only the string, and an app-provided label
|
|
133
|
+
// replaces it).
|
|
134
|
+
static NSAttributedString *ReactNativeAttributedText(UIView *view) {
|
|
135
|
+
NSString *className = NSStringFromClass([view class]);
|
|
136
|
+
id value = nil;
|
|
137
|
+
@try {
|
|
138
|
+
if ([className isEqualToString:@"RCTParagraphComponentView"] &&
|
|
139
|
+
[view respondsToSelector:@selector(attributedText)]) {
|
|
140
|
+
value = [(id)view attributedText];
|
|
141
|
+
} else if ([className isEqualToString:@"RCTTextView"]) {
|
|
142
|
+
// Legacy architecture: no public getter, the storage lives in the `_textStorage` ivar.
|
|
143
|
+
value = [view valueForKey:@"textStorage"];
|
|
144
|
+
}
|
|
145
|
+
} @catch (NSException *e) {
|
|
146
|
+
value = nil;
|
|
147
|
+
}
|
|
148
|
+
return [value isKindOfClass:[NSAttributedString class]] ? (NSAttributedString *)value : nil;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// CSS-style weight (100-900) from the font's UIFontWeightTrait (-1.0 to 1.0).
|
|
152
|
+
static NSInteger CSSFontWeight(UIFont *font) {
|
|
153
|
+
NSDictionary *traits = [font.fontDescriptor objectForKey:UIFontDescriptorTraitsAttribute];
|
|
154
|
+
NSNumber *weight = [traits isKindOfClass:[NSDictionary class]] ? traits[UIFontWeightTrait] : nil;
|
|
155
|
+
if (![weight isKindOfClass:[NSNumber class]]) {
|
|
156
|
+
return (font.fontDescriptor.symbolicTraits & UIFontDescriptorTraitBold) ? 700 : 400;
|
|
157
|
+
}
|
|
158
|
+
CGFloat w = weight.doubleValue;
|
|
159
|
+
if (w <= UIFontWeightLight) return 300;
|
|
160
|
+
if (w < UIFontWeightMedium) return 400;
|
|
161
|
+
if (w < UIFontWeightSemibold) return 500;
|
|
162
|
+
if (w < UIFontWeightBold) return 600;
|
|
163
|
+
return 700;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static void PutTextStyle(NSMutableDictionary *nodeDict, UIFont *font, UIColor *color, NSTextAlignment alignment) {
|
|
167
|
+
@try {
|
|
168
|
+
if ([font isKindOfClass:[UIFont class]] && font.pointSize > 0) {
|
|
169
|
+
nodeDict[@"fontSize"] = @(round(font.pointSize * 10.0) / 10.0);
|
|
170
|
+
nodeDict[@"fontWeight"] = @(CSSFontWeight(font));
|
|
171
|
+
}
|
|
172
|
+
NSString *hex = [color isKindOfClass:[UIColor class]] ? HexColorStringFromUIColor(color) : nil;
|
|
173
|
+
if (hex) {
|
|
174
|
+
nodeDict[@"color"] = hex;
|
|
175
|
+
}
|
|
176
|
+
if (alignment == NSTextAlignmentCenter) {
|
|
177
|
+
nodeDict[@"textAlign"] = @"center";
|
|
178
|
+
} else if (alignment == NSTextAlignmentRight) {
|
|
179
|
+
nodeDict[@"textAlign"] = @"right";
|
|
180
|
+
} else {
|
|
181
|
+
nodeDict[@"textAlign"] = @"left";
|
|
182
|
+
}
|
|
183
|
+
} @catch (NSException *e) {
|
|
184
|
+
// Style is best effort: the text alone still makes the replay readable.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
static void PutAttributedTextStyle(NSMutableDictionary *nodeDict, NSAttributedString *text) {
|
|
189
|
+
if (text.length == 0) return;
|
|
190
|
+
NSDictionary *attrs = [text attributesAtIndex:0 effectiveRange:NULL];
|
|
191
|
+
NSParagraphStyle *paragraph = attrs[NSParagraphStyleAttributeName];
|
|
192
|
+
NSTextAlignment alignment = [paragraph isKindOfClass:[NSParagraphStyle class]] ? paragraph.alignment : NSTextAlignmentNatural;
|
|
193
|
+
PutTextStyle(nodeDict, attrs[NSFontAttributeName], attrs[NSForegroundColorAttributeName], alignment);
|
|
194
|
+
}
|
|
195
|
+
|
|
124
196
|
static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteger depth) {
|
|
125
197
|
if (depth > 64) {
|
|
126
198
|
return nil;
|
|
@@ -218,21 +290,42 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
|
|
|
218
290
|
if (identifier.length > 0) {
|
|
219
291
|
nodeDict[@"accessibilityIdentifier"] = identifier;
|
|
220
292
|
}
|
|
221
|
-
|
|
222
|
-
|
|
293
|
+
// Reuse the label read above: on RCTParagraphComponentView every read rebuilds the
|
|
294
|
+
// string from the shadow tree state.
|
|
295
|
+
if (accLabel.length > 0) {
|
|
296
|
+
nodeDict[@"accessibilityLabel"] = isMasked ? @"***" : accLabel;
|
|
223
297
|
}
|
|
224
298
|
|
|
225
|
-
// Text extraction from UILabel, UITextField, UITextView, UIButton
|
|
299
|
+
// Text extraction from the React Native <Text>, UILabel, UITextField, UITextView, UIButton.
|
|
300
|
+
// Style is read later, only for unmasked nodes with text: reading a UIButton's titleLabel
|
|
301
|
+
// creates one on image-only buttons, and a passive recorder must not change the host UI.
|
|
226
302
|
NSString *extractedText = nil;
|
|
227
|
-
|
|
303
|
+
void (^putStyle)(void) = nil;
|
|
304
|
+
NSAttributedString *reactNativeText = ReactNativeAttributedText(view);
|
|
305
|
+
if (reactNativeText) {
|
|
306
|
+
// Copy: on the legacy architecture this is the live backing store of the NSTextStorage.
|
|
307
|
+
extractedText = [reactNativeText.string copy];
|
|
308
|
+
putStyle = ^{
|
|
309
|
+
PutAttributedTextStyle(nodeDict, reactNativeText);
|
|
310
|
+
};
|
|
311
|
+
} else if ([view isKindOfClass:[UILabel class]]) {
|
|
228
312
|
UILabel *label = (UILabel *)view;
|
|
229
313
|
extractedText = label.text ?: (label.attributedText ? label.attributedText.string : nil);
|
|
314
|
+
putStyle = ^{
|
|
315
|
+
PutTextStyle(nodeDict, label.font, label.textColor, label.textAlignment);
|
|
316
|
+
};
|
|
230
317
|
} else if ([view isKindOfClass:[UITextField class]]) {
|
|
231
318
|
UITextField *textField = (UITextField *)view;
|
|
232
319
|
extractedText = textField.text ?: (textField.attributedText ? textField.attributedText.string : nil);
|
|
320
|
+
putStyle = ^{
|
|
321
|
+
PutTextStyle(nodeDict, textField.font, textField.textColor, textField.textAlignment);
|
|
322
|
+
};
|
|
233
323
|
} else if ([view isKindOfClass:[UITextView class]]) {
|
|
234
324
|
UITextView *textView = (UITextView *)view;
|
|
235
325
|
extractedText = textView.text ?: (textView.attributedText ? textView.attributedText.string : nil);
|
|
326
|
+
putStyle = ^{
|
|
327
|
+
PutTextStyle(nodeDict, textView.font, textView.textColor, textView.textAlignment);
|
|
328
|
+
};
|
|
236
329
|
} else if ([view isKindOfClass:[UIButton class]]) {
|
|
237
330
|
UIButton *button = (UIButton *)view;
|
|
238
331
|
if (@available(iOS 15.0, *)) {
|
|
@@ -243,10 +336,21 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
|
|
|
243
336
|
if (!extractedText) {
|
|
244
337
|
extractedText = [button titleForState:UIControlStateNormal] ?: (button.currentTitle ?: (button.currentAttributedTitle ? button.currentAttributedTitle.string : nil));
|
|
245
338
|
}
|
|
339
|
+
putStyle = ^{
|
|
340
|
+
PutTextStyle(nodeDict, button.titleLabel.font, button.currentTitleColor, NSTextAlignmentCenter);
|
|
341
|
+
};
|
|
246
342
|
}
|
|
247
343
|
|
|
248
344
|
if (extractedText) {
|
|
249
345
|
nodeDict[@"text"] = isMasked ? @"***" : extractedText;
|
|
346
|
+
if (!isMasked && extractedText.length > 0 && putStyle) {
|
|
347
|
+
putStyle();
|
|
348
|
+
}
|
|
349
|
+
// The text view's accessibilityLabel defaults to its own string: sending both doubles
|
|
350
|
+
// the payload of every <Text> for nothing.
|
|
351
|
+
if (!isMasked && [nodeDict[@"accessibilityLabel"] isEqual:extractedText]) {
|
|
352
|
+
[nodeDict removeObjectForKey:@"accessibilityLabel"];
|
|
353
|
+
}
|
|
250
354
|
}
|
|
251
355
|
|
|
252
356
|
if (isMasked) {
|
|
@@ -278,7 +382,7 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
|
|
|
278
382
|
RCT_EXPORT_MODULE(ClintplayRN)
|
|
279
383
|
|
|
280
384
|
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersion) {
|
|
281
|
-
return @"0.1
|
|
385
|
+
return @"0.2.1";
|
|
282
386
|
}
|
|
283
387
|
|
|
284
388
|
- (NSDictionary *)extractHierarchyDictionaryOnMainThread {
|
|
@@ -314,7 +418,9 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersion) {
|
|
|
314
418
|
return @"{\"version\":1,\"nodes\":[]}";
|
|
315
419
|
}
|
|
316
420
|
NSError *error = nil;
|
|
317
|
-
|
|
421
|
+
// Sorted keys keep an unchanged screen byte-identical between captures, which is what
|
|
422
|
+
// lets the JS recorder skip re-sending it.
|
|
423
|
+
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload options:NSJSONWritingSortedKeys error:&error];
|
|
318
424
|
if (error || !jsonData) {
|
|
319
425
|
return @"{\"version\":1,\"nodes\":[]}";
|
|
320
426
|
}
|
package/package.json
CHANGED