@nanogiants/react-native-render-html 1.0.0

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/src/assets.ts ADDED
@@ -0,0 +1,5 @@
1
+ export const EXTERNAL_LINK_URI =
2
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAYAAACpSkzOAAAAAXNSR0IB2cksfwAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAuIwAALiMBeKU/dgAAAAd0SU1FB+kJCwwjMljq2h8AAAD4SURBVEjH7dY9SgNBGMbxn7vpIngXwc5SbG39KjxCLDyCnY0nUFGIH0Uu4CU8gXZ2iYU2IsRmiiCz+u4sBpQ8sLwMDPPf53mWYVnoP2sH44bn8evmXgfQM+5n1jW20lz6LXc9DDHFByYRR2foBw4/xkM64xLbuMYy1iNvN8H7N/mPMcg4Gab1KOeoCTQK7KuTkyluZ9LJgqrCTmqcYx932E3dtNJPjnJxwWqamzjoCqpxlYlrEIw7BGqChHqtWnRygb3STqKgkwS5KS0+egWdpnlUAmkDesJhlzuqMifNDdQU3Vr6hKPql4BesIKNFqBXvC3+Bf62PgFLzkKE8ZczlwAAAABJRU5ErkJggg==';
3
+
4
+ export const UL_MARKER_URI =
5
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAwCAYAAAAsJjtLAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAACKADAAQAAAABAAAAMAAAAAAE1YgVAAAAZklEQVQ4EWNgGAWjITC4QoDxiYRE/H9GxgkM//8LIDuNkYHhAZDfAFJw/z8DgwKyJJzNyPiBCackSBXQVCa4ahwMaigAOgSH6QwgnzD9//+/EOolVHVAjf8YGBpRBUd5oyEw8CEAAGO5FkZ9+flOAAAAAElFTkSuQmCC';
@@ -0,0 +1,25 @@
1
+ import type { FunctionComponent, PropsWithChildren } from 'react';
2
+ import { type StyleProp, View, type ViewStyle } from 'react-native';
3
+
4
+ import { useAlignedWidth } from './AlignedWidthProvider';
5
+
6
+ interface Props extends PropsWithChildren {
7
+ index: number;
8
+ style?: StyleProp<ViewStyle>;
9
+ }
10
+
11
+ /**
12
+ * This component is used to wrap items that need to have aligned widths.
13
+ * It uses the AlignedWidthProvider context to measure and set the width
14
+ * of each item based on the widest item in the same index position.
15
+ * For example, in a table column.
16
+ */
17
+ export const AlignedWidthItem: FunctionComponent<Props> = ({ children, index, style }) => {
18
+ const { getLayoutHandlerForIndex, getWidthStyle } = useAlignedWidth();
19
+
20
+ return (
21
+ <View style={[getWidthStyle(index), style]} onLayout={getLayoutHandlerForIndex(index)}>
22
+ {children}
23
+ </View>
24
+ );
25
+ };
@@ -0,0 +1,52 @@
1
+ import { createContext, type ReactNode, useContext, useState } from 'react';
2
+ import type { LayoutChangeEvent } from 'react-native';
3
+
4
+ interface AlignedWidthContextProps {
5
+ getLayoutHandlerForIndex: (colIdx: number) => (event: LayoutChangeEvent) => void;
6
+ getWidthStyle: (colIdx: number) => { minWidth?: number; width?: number };
7
+ }
8
+
9
+ const AlignedWidthContext = createContext<AlignedWidthContextProps | undefined>(undefined);
10
+
11
+ export const AlignedWidthProvider = ({ children }: { children: ReactNode }) => {
12
+ const [colWidths, setColWidths] = useState<{ [colIdx: number]: number }>({});
13
+
14
+ const getLayoutHandlerForIndex = (colIdx: number) => {
15
+ return (event: LayoutChangeEvent) => {
16
+ const { width } = event.nativeEvent.layout;
17
+ setColWidths((prev) => {
18
+ const previousWidth = prev[colIdx] || 0;
19
+ return {
20
+ ...prev,
21
+ [colIdx]: Math.max(previousWidth, width),
22
+ };
23
+ });
24
+ };
25
+ };
26
+
27
+ const getWidthStyle = (colIdx: number) => {
28
+ return {
29
+ minWidth: colWidths[colIdx] || undefined,
30
+ width: colWidths[colIdx] || undefined,
31
+ };
32
+ };
33
+
34
+ return (
35
+ <AlignedWidthContext.Provider
36
+ value={{
37
+ getLayoutHandlerForIndex,
38
+ getWidthStyle,
39
+ }}
40
+ >
41
+ {children}
42
+ </AlignedWidthContext.Provider>
43
+ );
44
+ };
45
+
46
+ export const useAlignedWidth = () => {
47
+ const context = useContext(AlignedWidthContext);
48
+ if (!context) {
49
+ throw new Error('useAlignedWidth must be used within a AlignedWidthProvider');
50
+ }
51
+ return context;
52
+ };
@@ -0,0 +1,564 @@
1
+ import type { AnyNode, ChildNode } from 'domhandler';
2
+ import type { FunctionComponent } from 'react';
3
+ import { createContext, type ReactNode, useCallback, useContext, useMemo } from 'react';
4
+ import type { ColorValue, ImageProps, TextProps, TextStyle, ViewStyle } from 'react-native';
5
+ import { Image, Text } from 'react-native';
6
+
7
+ import type { CommonProps, HTMLTag, TagStyles } from '../types';
8
+
9
+ export interface OnHTMLLinkPress {
10
+ url: string;
11
+ title: string;
12
+ }
13
+
14
+ interface ContextValue {
15
+ getStyle: (node: AnyNode | ChildNode) => {
16
+ text: TextStyle;
17
+ block: ViewStyle;
18
+ };
19
+ baseStyle: TextStyle;
20
+ tagsStyles: TagStyles;
21
+ overrideExternalLinkTintColor?: ColorValue;
22
+ onLinkPress?: (options: OnHTMLLinkPress) => void;
23
+ markerColor?: string;
24
+ renderImage: (props: ImageProps) => ReactNode;
25
+ renderText: (props: TextProps) => ReactNode;
26
+ }
27
+
28
+ const HtmlContext = createContext<ContextValue | null>(null);
29
+
30
+ interface ProviderProps extends CommonProps {
31
+ children: ReactNode;
32
+ }
33
+
34
+ export const HtmlProvider: FunctionComponent<ProviderProps> = (options) => {
35
+ const baseStyle = useMemo<TextStyle>(() => {
36
+ return {
37
+ ...options.baseStyle,
38
+ };
39
+ }, [options.baseStyle]);
40
+
41
+ const tagsStyles = useMemo<TagStyles>(() => {
42
+ // Sorted by renderer type: block first, then text
43
+ return {
44
+ // BLOCK RENDERER TAGS
45
+ thead: {
46
+ text: {
47
+ ...baseStyle,
48
+ ...options.tagStyles?.thead?.text,
49
+ },
50
+ block: {
51
+ ...options.tagStyles?.thead?.block,
52
+ },
53
+ },
54
+ tbody: {
55
+ text: {
56
+ ...baseStyle,
57
+ ...options.tagStyles?.tbody?.text,
58
+ },
59
+ block: {
60
+ ...options.tagStyles?.tbody?.block,
61
+ },
62
+ },
63
+ tfoot: {
64
+ text: {
65
+ ...baseStyle,
66
+ ...options.tagStyles?.tfoot?.text,
67
+ },
68
+ block: {
69
+ ...options.tagStyles?.tfoot?.block,
70
+ },
71
+ },
72
+ blockquote: {
73
+ text: {
74
+ ...baseStyle,
75
+ fontStyle: 'italic',
76
+ ...options.tagStyles?.blockquote?.text,
77
+ },
78
+ block: {
79
+ borderLeftWidth: 4,
80
+ borderLeftColor: 'black',
81
+ marginLeft: 8,
82
+ paddingLeft: 12,
83
+ backgroundColor: '#cccccc',
84
+ ...options.tagStyles?.blockquote?.block,
85
+ },
86
+ },
87
+ ul: {
88
+ text: {
89
+ ...baseStyle,
90
+ ...options.tagStyles?.ul?.text,
91
+ },
92
+ block: {
93
+ paddingLeft: 12,
94
+ paddingVertical: 0,
95
+ gap: options.listGap ?? 0,
96
+ ...options.tagStyles?.ul?.block,
97
+ },
98
+ },
99
+ ol: {
100
+ text: {
101
+ ...baseStyle,
102
+ ...options.tagStyles?.ol?.text,
103
+ },
104
+ block: {
105
+ paddingLeft: 12,
106
+ paddingVertical: 0,
107
+ gap: options.listGap ?? 0,
108
+ ...options.tagStyles?.ol?.block,
109
+ },
110
+ },
111
+ dl: {
112
+ text: {
113
+ ...baseStyle,
114
+ ...options.tagStyles?.dl?.text,
115
+ },
116
+ block: {
117
+ ...options.tagStyles?.dl?.block,
118
+ },
119
+ },
120
+ div: {
121
+ text: {
122
+ ...baseStyle,
123
+ ...options.tagStyles?.div?.text,
124
+ },
125
+ block: {
126
+ ...options.tagStyles?.div?.block,
127
+ },
128
+ },
129
+ main: {
130
+ text: {
131
+ ...baseStyle,
132
+ ...options.tagStyles?.main?.text,
133
+ },
134
+ block: {
135
+ ...options.tagStyles?.main?.block,
136
+ },
137
+ },
138
+ section: {
139
+ text: {
140
+ ...baseStyle,
141
+ ...options.tagStyles?.section?.text,
142
+ },
143
+ block: {
144
+ marginVertical: 8,
145
+ ...options.tagStyles?.section?.block,
146
+ },
147
+ },
148
+ article: {
149
+ text: {
150
+ ...baseStyle,
151
+ ...options.tagStyles?.article?.text,
152
+ },
153
+ block: {
154
+ marginVertical: 8,
155
+ ...options.tagStyles?.article?.block,
156
+ },
157
+ },
158
+ aside: {
159
+ text: {
160
+ ...baseStyle,
161
+ ...options.tagStyles?.aside?.text,
162
+ },
163
+ block: {
164
+ ...options.tagStyles?.aside?.block,
165
+ },
166
+ },
167
+ nav: {
168
+ text: {
169
+ ...baseStyle,
170
+ ...options.tagStyles?.nav?.text,
171
+ },
172
+ block: {
173
+ ...options.tagStyles?.nav?.block,
174
+ },
175
+ },
176
+ header: {
177
+ text: {
178
+ ...baseStyle,
179
+ ...options.tagStyles?.header?.text,
180
+ },
181
+ block: {
182
+ marginBottom: 8,
183
+ ...options.tagStyles?.header?.block,
184
+ },
185
+ },
186
+ footer: {
187
+ text: {
188
+ ...baseStyle,
189
+ ...options.tagStyles?.footer?.text,
190
+ },
191
+ block: {
192
+ marginTop: 8,
193
+ ...options.tagStyles?.footer?.block,
194
+ },
195
+ },
196
+ hr: {
197
+ text: {
198
+ ...options.tagStyles?.hr?.text,
199
+ },
200
+ block: {
201
+ borderBottomWidth: 1,
202
+ borderBottomColor: 'black',
203
+ marginVertical: 12,
204
+ ...options.tagStyles?.hr?.block,
205
+ },
206
+ },
207
+ br: {
208
+ text: {
209
+ ...options.tagStyles?.br?.text,
210
+ },
211
+ block: {
212
+ height: 16,
213
+ ...options.tagStyles?.br?.block,
214
+ },
215
+ },
216
+
217
+ img: {
218
+ text: {
219
+ ...options.tagStyles?.img?.text,
220
+ },
221
+ block: {
222
+ marginVertical: 8,
223
+
224
+ flex: 1,
225
+ width: undefined,
226
+ height: 100,
227
+
228
+ ...options.tagStyles?.img?.block,
229
+ },
230
+ },
231
+ table: {
232
+ text: {
233
+ ...options.tagStyles?.table?.text,
234
+ },
235
+ block: {
236
+ marginVertical: 8,
237
+ ...options.tagStyles?.table?.block,
238
+ },
239
+ },
240
+ tr: {
241
+ text: {
242
+ ...baseStyle,
243
+ ...options.tagStyles?.tr?.text,
244
+ },
245
+ block: {
246
+ ...options.tagStyles?.tr?.block,
247
+ },
248
+ },
249
+
250
+ // TEXT RENDERER TAGS
251
+ h1: {
252
+ text: {
253
+ ...baseStyle,
254
+ fontSize: 32,
255
+ lineHeight: 38.4,
256
+ ...options.tagStyles?.h1?.text,
257
+ },
258
+ block: {
259
+ marginTop: 12,
260
+ marginBottom: 8,
261
+ ...options.tagStyles?.h1?.block,
262
+ },
263
+ },
264
+ h2: {
265
+ text: {
266
+ ...baseStyle,
267
+ fontSize: 24,
268
+ lineHeight: 28.8,
269
+ ...options.tagStyles?.h2?.text,
270
+ },
271
+ block: {
272
+ marginTop: 10,
273
+ marginBottom: 6,
274
+ ...options.tagStyles?.h2?.block,
275
+ },
276
+ },
277
+ h3: {
278
+ text: {
279
+ ...baseStyle,
280
+ fontSize: 18.72,
281
+ lineHeight: 22.46,
282
+ ...options.tagStyles?.h3?.text,
283
+ },
284
+ block: {
285
+ marginTop: 8,
286
+ marginBottom: 6,
287
+ ...options.tagStyles?.h3?.block,
288
+ },
289
+ },
290
+ h4: {
291
+ text: {
292
+ ...baseStyle,
293
+ fontSize: 16,
294
+ lineHeight: 19.2,
295
+ ...options.tagStyles?.h4?.text,
296
+ },
297
+ block: {
298
+ marginTop: 8,
299
+ marginBottom: 4,
300
+ ...options.tagStyles?.h4?.block,
301
+ },
302
+ },
303
+ h5: {
304
+ text: {
305
+ ...baseStyle,
306
+ fontSize: 13.28,
307
+ lineHeight: 15.94,
308
+ ...options.tagStyles?.h5?.text,
309
+ },
310
+ block: {
311
+ marginTop: 6,
312
+ marginBottom: 4,
313
+ ...options.tagStyles?.h5?.block,
314
+ },
315
+ },
316
+ h6: {
317
+ text: {
318
+ ...baseStyle,
319
+ fontSize: 10.72,
320
+ lineHeight: 12.86,
321
+ ...options.tagStyles?.h6?.text,
322
+ },
323
+ block: {
324
+ marginTop: 6,
325
+ marginBottom: 4,
326
+ ...options.tagStyles?.h6?.block,
327
+ },
328
+ },
329
+ p: {
330
+ text: {
331
+ ...baseStyle,
332
+ ...options.tagStyles?.p?.text,
333
+ },
334
+ block: {
335
+ marginTop: 8,
336
+ marginBottom: 8,
337
+ ...options.tagStyles?.p?.block,
338
+ },
339
+ },
340
+ a: {
341
+ text: {
342
+ ...baseStyle,
343
+ textDecorationLine: 'underline',
344
+ ...options.tagStyles?.a?.text,
345
+ },
346
+ },
347
+ li: {
348
+ text: {
349
+ ...baseStyle,
350
+ ...options.tagStyles?.li?.text,
351
+ },
352
+ block: {
353
+ paddingLeft: 0,
354
+ ...options.tagStyles?.li?.block,
355
+ },
356
+ },
357
+ pre: {
358
+ text: {
359
+ ...baseStyle,
360
+ fontFamily: 'monospace',
361
+ backgroundColor: '#bbbbbb',
362
+ ...options.tagStyles?.pre?.text,
363
+ },
364
+ },
365
+ code: {
366
+ text: {
367
+ ...baseStyle,
368
+ fontFamily: 'monospace',
369
+ backgroundColor: '#bbbbbb',
370
+ ...options.tagStyles?.code?.text,
371
+ },
372
+ },
373
+
374
+ // inline text styles
375
+ b: {
376
+ text: {
377
+ fontWeight: 'bold',
378
+ ...options.tagStyles?.b?.text,
379
+ },
380
+ },
381
+ strong: {
382
+ text: {
383
+ fontWeight: 'bold',
384
+ ...options.tagStyles?.strong?.text,
385
+ },
386
+ },
387
+ i: {
388
+ text: {
389
+ fontStyle: 'italic',
390
+ ...options.tagStyles?.i?.text,
391
+ },
392
+ },
393
+ em: {
394
+ text: {
395
+ fontStyle: 'italic',
396
+ ...options.tagStyles?.em?.text,
397
+ },
398
+ },
399
+ u: {
400
+ text: {
401
+ textDecorationLine: 'underline',
402
+ ...options.tagStyles?.u?.text,
403
+ },
404
+ },
405
+ mark: {
406
+ text: {
407
+ ...baseStyle,
408
+ backgroundColor: '#fff59d',
409
+ ...options.tagStyles?.mark?.text,
410
+ },
411
+ },
412
+ small: {
413
+ text: {
414
+ ...baseStyle,
415
+ fontSize: Math.max((baseStyle.fontSize as number) - 2, 12),
416
+ ...options.tagStyles?.small?.text,
417
+ },
418
+ },
419
+ s: {
420
+ text: {
421
+ ...baseStyle,
422
+ textDecorationLine: 'line-through',
423
+ ...options.tagStyles?.s?.text,
424
+ },
425
+ },
426
+ del: {
427
+ text: {
428
+ ...baseStyle,
429
+ textDecorationLine: 'line-through',
430
+ ...options.tagStyles?.del?.text,
431
+ },
432
+ },
433
+ sup: {
434
+ text: {
435
+ ...baseStyle,
436
+ textAlignVertical: 'top',
437
+ fontSize: Math.max((baseStyle.fontSize as number) - 4, 10),
438
+ ...options.tagStyles?.sup?.text,
439
+ },
440
+ },
441
+ sub: {
442
+ text: {
443
+ ...baseStyle,
444
+ textAlignVertical: 'bottom',
445
+ fontSize: Math.max((baseStyle.fontSize as number) - 4, 10),
446
+ ...options.tagStyles?.sub?.text,
447
+ },
448
+ },
449
+ th: {
450
+ text: {
451
+ ...baseStyle,
452
+ fontWeight: 'bold',
453
+ padding: 8,
454
+ borderWidth: 1,
455
+ borderColor: 'black',
456
+ ...options.tagStyles?.th?.text,
457
+ },
458
+ },
459
+ td: {
460
+ text: {
461
+ ...baseStyle,
462
+ padding: 8,
463
+ ...options.tagStyles?.td?.text,
464
+ },
465
+ },
466
+ dt: {
467
+ text: {
468
+ ...baseStyle,
469
+ ...options.tagStyles?.dt?.text,
470
+ },
471
+ },
472
+ dd: {
473
+ text: {
474
+ ...baseStyle,
475
+ marginLeft: 20,
476
+ ...options.tagStyles?.dd?.text,
477
+ },
478
+ },
479
+ span: {
480
+ text: {
481
+ ...baseStyle,
482
+ ...options.tagStyles?.span?.text,
483
+ },
484
+ },
485
+ };
486
+ }, [baseStyle, options.tagStyles, options.listGap]);
487
+
488
+ const getStyle = useCallback(
489
+ (
490
+ node: AnyNode | ChildNode,
491
+ ): {
492
+ text: TextStyle;
493
+ block: ViewStyle;
494
+ } => {
495
+ if (node.type === 'tag') {
496
+ const classStyle = node.attribs.class
497
+ ? options.classesStyles?.[node.attribs.class] || {}
498
+ : {};
499
+ return {
500
+ text: {
501
+ ...tagsStyles[node.tagName as HTMLTag].text,
502
+ ...classStyle.text,
503
+ },
504
+ block: {
505
+ ...(tagsStyles[node.tagName as HTMLTag].block ?? {}),
506
+ ...classStyle.block,
507
+ },
508
+ };
509
+ }
510
+ return {
511
+ text: {},
512
+ block: {},
513
+ };
514
+ },
515
+ [tagsStyles, options.classesStyles],
516
+ );
517
+
518
+ const renderImage = useMemo<(props: ImageProps) => ReactNode>(() => {
519
+ if (options.renderImage) {
520
+ return options.renderImage;
521
+ }
522
+ return (props: ImageProps) => <Image {...props} />;
523
+ }, [options.renderImage]);
524
+
525
+ const renderText = useMemo<(props: TextProps) => ReactNode>(() => {
526
+ if (options.renderTextComponent) {
527
+ return options.renderTextComponent;
528
+ }
529
+ return (props: TextProps) => <Text {...props} />;
530
+ }, [options.renderTextComponent]);
531
+
532
+ const providerValue = useMemo(
533
+ () => ({
534
+ getStyle,
535
+ baseStyle,
536
+ tagsStyles,
537
+ overrideExternalLinkTintColor: options.overrideExternalLinkTintColor,
538
+ onLinkPress: options.onLinkPress,
539
+ markerColor: options.markerColor,
540
+ renderImage,
541
+ renderText,
542
+ }),
543
+ [
544
+ getStyle,
545
+ baseStyle,
546
+ tagsStyles,
547
+ options.overrideExternalLinkTintColor,
548
+ options.onLinkPress,
549
+ options.markerColor,
550
+ renderImage,
551
+ renderText,
552
+ ],
553
+ );
554
+
555
+ return <HtmlContext.Provider value={providerValue}>{options.children}</HtmlContext.Provider>;
556
+ };
557
+
558
+ export const useHtmlContext = () => {
559
+ const context = useContext(HtmlContext);
560
+ if (!context) {
561
+ throw new Error('useHtml must be used within a HtmlProvider');
562
+ }
563
+ return context;
564
+ };
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type { OnHTMLLinkPress } from './context/HtmlProvider';
2
+ export type { RenderHTMLProps } from './RenderHTML';
3
+ export { RenderHTML } from './RenderHTML';
4
+ export type { HTMLTag, HtmlStyle, TagStyles } from './types';
@@ -0,0 +1,60 @@
1
+ import type { Element } from 'domhandler';
2
+ import type { FunctionComponent } from 'react';
3
+
4
+ import { EXTERNAL_LINK_URI } from '../assets';
5
+ import { useHtmlContext } from '../context/HtmlProvider';
6
+ import { concatTextNodes, isExternalURL } from '../utils';
7
+
8
+ interface Props {
9
+ node: Element;
10
+ }
11
+
12
+ export const ATagRenderer: FunctionComponent<Props> = ({ node }) => {
13
+ const { overrideExternalLinkTintColor, onLinkPress, getStyle, renderImage, renderText } =
14
+ useHtmlContext();
15
+
16
+ const text = concatTextNodes(node.children);
17
+ if (!text) {
18
+ return null;
19
+ }
20
+ const style = getStyle(node);
21
+
22
+ const fontSize = style.text?.fontSize ?? 18;
23
+
24
+ const isExternal = node.attribs.href ? isExternalURL(node.attribs.href) : false;
25
+
26
+ return (
27
+ <>
28
+ {renderText({
29
+ accessible: true,
30
+ onPress: () => {
31
+ onLinkPress?.({
32
+ url: node.attribs.href,
33
+ title: text,
34
+ });
35
+ },
36
+ accessibilityRole: 'link',
37
+ accessibilityHint: isExternal ? 'External link' : undefined,
38
+ importantForAccessibility: 'yes',
39
+ accessibilityElementsHidden: false,
40
+ style: style.text,
41
+ children: (
42
+ <>
43
+ {text}
44
+ {isExternal
45
+ ? renderImage({
46
+ source: { uri: EXTERNAL_LINK_URI },
47
+ style: {
48
+ tintColor: overrideExternalLinkTintColor ?? style.text.color ?? 'black',
49
+ height: fontSize,
50
+ width: fontSize,
51
+ paddingTop: 5,
52
+ },
53
+ })
54
+ : null}
55
+ </>
56
+ ),
57
+ })}
58
+ </>
59
+ );
60
+ };