clintplay-react-native 0.2.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 +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +64 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +64 -35
- package/dist/index.mjs.map +1 -1
- package/ios/ClintplayRN.mm +107 -6
- package/package.json +1 -1
package/ios/ClintplayRN.mm
CHANGED
|
@@ -126,6 +126,73 @@ static NSString *HexColorStringFromUIColor(UIColor *color) {
|
|
|
126
126
|
return nil;
|
|
127
127
|
}
|
|
128
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
|
+
|
|
129
196
|
static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteger depth) {
|
|
130
197
|
if (depth > 64) {
|
|
131
198
|
return nil;
|
|
@@ -223,21 +290,42 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
|
|
|
223
290
|
if (identifier.length > 0) {
|
|
224
291
|
nodeDict[@"accessibilityIdentifier"] = identifier;
|
|
225
292
|
}
|
|
226
|
-
|
|
227
|
-
|
|
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;
|
|
228
297
|
}
|
|
229
298
|
|
|
230
|
-
// 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.
|
|
231
302
|
NSString *extractedText = nil;
|
|
232
|
-
|
|
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]]) {
|
|
233
312
|
UILabel *label = (UILabel *)view;
|
|
234
313
|
extractedText = label.text ?: (label.attributedText ? label.attributedText.string : nil);
|
|
314
|
+
putStyle = ^{
|
|
315
|
+
PutTextStyle(nodeDict, label.font, label.textColor, label.textAlignment);
|
|
316
|
+
};
|
|
235
317
|
} else if ([view isKindOfClass:[UITextField class]]) {
|
|
236
318
|
UITextField *textField = (UITextField *)view;
|
|
237
319
|
extractedText = textField.text ?: (textField.attributedText ? textField.attributedText.string : nil);
|
|
320
|
+
putStyle = ^{
|
|
321
|
+
PutTextStyle(nodeDict, textField.font, textField.textColor, textField.textAlignment);
|
|
322
|
+
};
|
|
238
323
|
} else if ([view isKindOfClass:[UITextView class]]) {
|
|
239
324
|
UITextView *textView = (UITextView *)view;
|
|
240
325
|
extractedText = textView.text ?: (textView.attributedText ? textView.attributedText.string : nil);
|
|
326
|
+
putStyle = ^{
|
|
327
|
+
PutTextStyle(nodeDict, textView.font, textView.textColor, textView.textAlignment);
|
|
328
|
+
};
|
|
241
329
|
} else if ([view isKindOfClass:[UIButton class]]) {
|
|
242
330
|
UIButton *button = (UIButton *)view;
|
|
243
331
|
if (@available(iOS 15.0, *)) {
|
|
@@ -248,10 +336,21 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
|
|
|
248
336
|
if (!extractedText) {
|
|
249
337
|
extractedText = [button titleForState:UIControlStateNormal] ?: (button.currentTitle ?: (button.currentAttributedTitle ? button.currentAttributedTitle.string : nil));
|
|
250
338
|
}
|
|
339
|
+
putStyle = ^{
|
|
340
|
+
PutTextStyle(nodeDict, button.titleLabel.font, button.currentTitleColor, NSTextAlignmentCenter);
|
|
341
|
+
};
|
|
251
342
|
}
|
|
252
343
|
|
|
253
344
|
if (extractedText) {
|
|
254
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
|
+
}
|
|
255
354
|
}
|
|
256
355
|
|
|
257
356
|
if (isMasked) {
|
|
@@ -283,7 +382,7 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
|
|
|
283
382
|
RCT_EXPORT_MODULE(ClintplayRN)
|
|
284
383
|
|
|
285
384
|
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersion) {
|
|
286
|
-
return @"0.2.
|
|
385
|
+
return @"0.2.1";
|
|
287
386
|
}
|
|
288
387
|
|
|
289
388
|
- (NSDictionary *)extractHierarchyDictionaryOnMainThread {
|
|
@@ -319,7 +418,9 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersion) {
|
|
|
319
418
|
return @"{\"version\":1,\"nodes\":[]}";
|
|
320
419
|
}
|
|
321
420
|
NSError *error = nil;
|
|
322
|
-
|
|
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];
|
|
323
424
|
if (error || !jsonData) {
|
|
324
425
|
return @"{\"version\":1,\"nodes\":[]}";
|
|
325
426
|
}
|
package/package.json
CHANGED