react-native-nitro-markdown 0.5.0 → 0.5.2
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 +68 -5
- package/android/src/main/cpp/cpp-adapter.cpp +4 -1
- package/android/src/main/java/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSession.kt +19 -0
- package/android/src/main/java/com/nitromarkdown/NitroMarkdownPackage.kt +4 -3
- package/ios/HybridMarkdownSession.swift +18 -0
- package/lib/commonjs/MarkdownContext.js.map +1 -1
- package/lib/commonjs/headless.js +20 -0
- package/lib/commonjs/headless.js.map +1 -1
- package/lib/commonjs/index.js +9 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/markdown.js +21 -11
- package/lib/commonjs/markdown.js.map +1 -1
- package/lib/commonjs/renderers/code.js +20 -2
- package/lib/commonjs/renderers/code.js.map +1 -1
- package/lib/commonjs/renderers/table/cell-content.js +32 -0
- package/lib/commonjs/renderers/table/cell-content.js.map +1 -0
- package/lib/commonjs/renderers/table/index.js +310 -0
- package/lib/commonjs/renderers/table/index.js.map +1 -0
- package/lib/commonjs/renderers/table/table-reducer.js +29 -0
- package/lib/commonjs/renderers/table/table-reducer.js.map +1 -0
- package/lib/commonjs/renderers/table/table-utils.js +68 -0
- package/lib/commonjs/renderers/table/table-utils.js.map +1 -0
- package/lib/commonjs/renderers/table/types.js +6 -0
- package/lib/commonjs/renderers/table/types.js.map +1 -0
- package/lib/commonjs/renderers/table.js +6 -398
- package/lib/commonjs/renderers/table.js.map +1 -1
- package/lib/commonjs/theme.js +10 -1
- package/lib/commonjs/theme.js.map +1 -1
- package/lib/commonjs/use-markdown-stream.js +9 -1
- package/lib/commonjs/use-markdown-stream.js.map +1 -1
- package/lib/commonjs/utils/code-highlight.js +101 -0
- package/lib/commonjs/utils/code-highlight.js.map +1 -0
- package/lib/module/MarkdownContext.js.map +1 -1
- package/lib/module/headless.js +19 -0
- package/lib/module/headless.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/markdown.js +21 -11
- package/lib/module/markdown.js.map +1 -1
- package/lib/module/renderers/code.js +20 -2
- package/lib/module/renderers/code.js.map +1 -1
- package/lib/module/renderers/table/cell-content.js +27 -0
- package/lib/module/renderers/table/cell-content.js.map +1 -0
- package/lib/module/renderers/table/index.js +305 -0
- package/lib/module/renderers/table/index.js.map +1 -0
- package/lib/module/renderers/table/table-reducer.js +24 -0
- package/lib/module/renderers/table/table-reducer.js.map +1 -0
- package/lib/module/renderers/table/table-utils.js +62 -0
- package/lib/module/renderers/table/table-utils.js.map +1 -0
- package/lib/module/renderers/table/types.js +4 -0
- package/lib/module/renderers/table/types.js.map +1 -0
- package/lib/module/renderers/table.js +1 -397
- package/lib/module/renderers/table.js.map +1 -1
- package/lib/module/theme.js +10 -1
- package/lib/module/theme.js.map +1 -1
- package/lib/module/use-markdown-stream.js +9 -1
- package/lib/module/use-markdown-stream.js.map +1 -1
- package/lib/module/utils/code-highlight.js +97 -0
- package/lib/module/utils/code-highlight.js.map +1 -0
- package/lib/typescript/commonjs/MarkdownContext.d.ts +6 -0
- package/lib/typescript/commonjs/MarkdownContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/headless.d.ts +5 -0
- package/lib/typescript/commonjs/headless.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +2 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/markdown.d.ts +24 -0
- package/lib/typescript/commonjs/markdown.d.ts.map +1 -1
- package/lib/typescript/commonjs/renderers/code.d.ts.map +1 -1
- package/lib/typescript/commonjs/renderers/table/cell-content.d.ts +15 -0
- package/lib/typescript/commonjs/renderers/table/cell-content.d.ts.map +1 -0
- package/lib/typescript/commonjs/renderers/table/index.d.ts +11 -0
- package/lib/typescript/commonjs/renderers/table/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/renderers/table/table-reducer.d.ts +5 -0
- package/lib/typescript/commonjs/renderers/table/table-reducer.d.ts.map +1 -0
- package/lib/typescript/commonjs/renderers/table/table-utils.d.ts +10 -0
- package/lib/typescript/commonjs/renderers/table/table-utils.d.ts.map +1 -0
- package/lib/typescript/commonjs/renderers/table/types.d.ts +24 -0
- package/lib/typescript/commonjs/renderers/table/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/renderers/table.d.ts +1 -11
- package/lib/typescript/commonjs/renderers/table.d.ts.map +1 -1
- package/lib/typescript/commonjs/specs/MarkdownSession.nitro.d.ts +9 -0
- package/lib/typescript/commonjs/specs/MarkdownSession.nitro.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme.d.ts +18 -2
- package/lib/typescript/commonjs/theme.d.ts.map +1 -1
- package/lib/typescript/commonjs/use-markdown-stream.d.ts +4 -0
- package/lib/typescript/commonjs/use-markdown-stream.d.ts.map +1 -1
- package/lib/typescript/commonjs/utils/code-highlight.d.ts +8 -0
- package/lib/typescript/commonjs/utils/code-highlight.d.ts.map +1 -0
- package/lib/typescript/module/MarkdownContext.d.ts +6 -0
- package/lib/typescript/module/MarkdownContext.d.ts.map +1 -1
- package/lib/typescript/module/headless.d.ts +5 -0
- package/lib/typescript/module/headless.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +2 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/markdown.d.ts +24 -0
- package/lib/typescript/module/markdown.d.ts.map +1 -1
- package/lib/typescript/module/renderers/code.d.ts.map +1 -1
- package/lib/typescript/module/renderers/table/cell-content.d.ts +15 -0
- package/lib/typescript/module/renderers/table/cell-content.d.ts.map +1 -0
- package/lib/typescript/module/renderers/table/index.d.ts +11 -0
- package/lib/typescript/module/renderers/table/index.d.ts.map +1 -0
- package/lib/typescript/module/renderers/table/table-reducer.d.ts +5 -0
- package/lib/typescript/module/renderers/table/table-reducer.d.ts.map +1 -0
- package/lib/typescript/module/renderers/table/table-utils.d.ts +10 -0
- package/lib/typescript/module/renderers/table/table-utils.d.ts.map +1 -0
- package/lib/typescript/module/renderers/table/types.d.ts +24 -0
- package/lib/typescript/module/renderers/table/types.d.ts.map +1 -0
- package/lib/typescript/module/renderers/table.d.ts +1 -11
- package/lib/typescript/module/renderers/table.d.ts.map +1 -1
- package/lib/typescript/module/specs/MarkdownSession.nitro.d.ts +9 -0
- package/lib/typescript/module/specs/MarkdownSession.nitro.d.ts.map +1 -1
- package/lib/typescript/module/theme.d.ts +18 -2
- package/lib/typescript/module/theme.d.ts.map +1 -1
- package/lib/typescript/module/use-markdown-stream.d.ts +4 -0
- package/lib/typescript/module/use-markdown-stream.d.ts.map +1 -1
- package/lib/typescript/module/utils/code-highlight.d.ts +8 -0
- package/lib/typescript/module/utils/code-highlight.d.ts.map +1 -0
- package/nitrogen/generated/android/NitroMarkdownOnLoad.cpp +37 -27
- package/nitrogen/generated/android/NitroMarkdownOnLoad.hpp +13 -4
- package/nitrogen/generated/android/c++/JHybridMarkdownSessionSpec.cpp +35 -32
- package/nitrogen/generated/android/c++/JHybridMarkdownSessionSpec.hpp +21 -22
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSessionSpec.kt +23 -18
- package/nitrogen/generated/ios/c++/HybridMarkdownSessionSpecSwift.hpp +14 -0
- package/nitrogen/generated/ios/swift/HybridMarkdownSessionSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMarkdownSessionSpec_cxx.swift +23 -0
- package/nitrogen/generated/shared/c++/HybridMarkdownSessionSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMarkdownSessionSpec.hpp +2 -0
- package/package.json +6 -3
- package/react-native-nitro-markdown.podspec +1 -1
- package/src/MarkdownContext.ts +6 -0
- package/src/headless.ts +12 -0
- package/src/index.ts +3 -0
- package/src/markdown.tsx +47 -7
- package/src/renderers/code.tsx +27 -2
- package/src/renderers/table/cell-content.tsx +38 -0
- package/src/renderers/table/index.tsx +419 -0
- package/src/renderers/table/table-reducer.ts +36 -0
- package/src/renderers/table/table-utils.ts +81 -0
- package/src/renderers/table/types.ts +24 -0
- package/src/renderers/table.tsx +1 -547
- package/src/specs/MarkdownSession.nitro.ts +10 -0
- package/src/theme.ts +29 -1
- package/src/use-markdown-stream.ts +10 -0
- package/src/utils/code-highlight.ts +102 -0
package/src/headless.ts
CHANGED
|
@@ -207,3 +207,15 @@ export const getFlattenedText = (node: MarkdownNode): string => {
|
|
|
207
207
|
return childrenText;
|
|
208
208
|
}
|
|
209
209
|
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Recursively removes `beg`/`end` source offset fields from an AST.
|
|
213
|
+
* Useful to reduce memory in environments that don't need source mapping.
|
|
214
|
+
*/
|
|
215
|
+
export function stripSourceOffsets(node: MarkdownNode): MarkdownNode {
|
|
216
|
+
const { beg: _beg, end: _end, children, ...rest } = node;
|
|
217
|
+
return {
|
|
218
|
+
...rest,
|
|
219
|
+
...(children ? { children: children.map(stripSourceOffsets) } : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -55,3 +55,6 @@ export { MathInline, MathBlock } from "./renderers/math";
|
|
|
55
55
|
export { createMarkdownSession } from "./MarkdownSession";
|
|
56
56
|
export type { MarkdownSession } from "./MarkdownSession";
|
|
57
57
|
export { useMarkdownSession, useStream } from "./use-markdown-stream";
|
|
58
|
+
|
|
59
|
+
export type { HighlightedToken, TokenType, CodeHighlighter } from "./utils/code-highlight";
|
|
60
|
+
export { defaultHighlighter } from "./utils/code-highlight";
|
package/src/markdown.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type ListRenderItemInfo,
|
|
18
18
|
type FlatListProps,
|
|
19
19
|
type StyleProp,
|
|
20
|
+
type TextStyle,
|
|
20
21
|
type ViewStyle,
|
|
21
22
|
} from "react-native";
|
|
22
23
|
import {
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
type NodeStyleOverrides,
|
|
54
55
|
type StylingStrategy,
|
|
55
56
|
} from "./theme";
|
|
57
|
+
import type { CodeHighlighter } from "./utils/code-highlight";
|
|
56
58
|
|
|
57
59
|
const baseStylesCache = new WeakMap<MarkdownTheme, BaseStyles>();
|
|
58
60
|
const parseAstCache = new Map<string, MarkdownNode>();
|
|
@@ -78,6 +80,10 @@ export type MarkdownPlugin = {
|
|
|
78
80
|
* Optional plugin version metadata for diagnostics.
|
|
79
81
|
*/
|
|
80
82
|
version?: string | number;
|
|
83
|
+
/**
|
|
84
|
+
* Execution priority. Higher values run first (default: 0).
|
|
85
|
+
*/
|
|
86
|
+
priority?: number;
|
|
81
87
|
/**
|
|
82
88
|
* Optional text preprocessor executed before native parsing.
|
|
83
89
|
* Should return a full markdown string.
|
|
@@ -183,13 +189,15 @@ const getCachedParsedAst = (
|
|
|
183
189
|
const applyBeforeParsePlugins = (
|
|
184
190
|
markdown: string,
|
|
185
191
|
plugins?: MarkdownPlugin[],
|
|
192
|
+
onError?: (error: Error, phase: 'before-plugin', pluginName?: string) => void,
|
|
186
193
|
): string => {
|
|
187
194
|
if (!plugins || plugins.length === 0) {
|
|
188
195
|
return markdown;
|
|
189
196
|
}
|
|
190
197
|
|
|
198
|
+
const sorted = [...plugins].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
191
199
|
let nextMarkdown = markdown;
|
|
192
|
-
for (const plugin of
|
|
200
|
+
for (const plugin of sorted) {
|
|
193
201
|
if (!plugin.beforeParse) continue;
|
|
194
202
|
|
|
195
203
|
try {
|
|
@@ -203,6 +211,7 @@ const applyBeforeParsePlugins = (
|
|
|
203
211
|
`[react-native-nitro-markdown] plugin beforeParse${pluginLabel} threw; using previous markdown.`,
|
|
204
212
|
error,
|
|
205
213
|
);
|
|
214
|
+
onError?.(error instanceof Error ? error : new Error(String(error)), 'before-plugin', plugin.name);
|
|
206
215
|
}
|
|
207
216
|
}
|
|
208
217
|
|
|
@@ -212,13 +221,15 @@ const applyBeforeParsePlugins = (
|
|
|
212
221
|
const applyAfterParsePlugins = (
|
|
213
222
|
ast: MarkdownNode,
|
|
214
223
|
plugins?: MarkdownPlugin[],
|
|
224
|
+
onError?: (error: Error, phase: 'after-plugin', pluginName?: string) => void,
|
|
215
225
|
): MarkdownNode => {
|
|
216
226
|
if (!plugins || plugins.length === 0) {
|
|
217
227
|
return ast;
|
|
218
228
|
}
|
|
219
229
|
|
|
230
|
+
const sorted = [...plugins].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
220
231
|
let nextAst = ast;
|
|
221
|
-
for (const plugin of
|
|
232
|
+
for (const plugin of sorted) {
|
|
222
233
|
if (!plugin.afterParse) continue;
|
|
223
234
|
|
|
224
235
|
try {
|
|
@@ -232,6 +243,7 @@ const applyAfterParsePlugins = (
|
|
|
232
243
|
`[react-native-nitro-markdown] plugin afterParse${pluginLabel} threw; using previous AST.`,
|
|
233
244
|
error,
|
|
234
245
|
);
|
|
246
|
+
onError?.(error instanceof Error ? error : new Error(String(error)), 'after-plugin', plugin.name);
|
|
235
247
|
}
|
|
236
248
|
}
|
|
237
249
|
|
|
@@ -273,6 +285,13 @@ export type MarkdownProps = {
|
|
|
273
285
|
ast: MarkdownNode;
|
|
274
286
|
text: string;
|
|
275
287
|
}) => void;
|
|
288
|
+
/**
|
|
289
|
+
* Called when a parse error or plugin error occurs.
|
|
290
|
+
* @param error - The thrown error.
|
|
291
|
+
* @param phase - Where the error occurred.
|
|
292
|
+
* @param pluginName - The plugin name, if applicable.
|
|
293
|
+
*/
|
|
294
|
+
onError?: (error: Error, phase: 'parse' | 'before-plugin' | 'after-plugin', pluginName?: string) => void;
|
|
276
295
|
/**
|
|
277
296
|
* Custom renderers for specific markdown node types.
|
|
278
297
|
* Each renderer receives { node, children, Renderer } plus type-specific props.
|
|
@@ -325,6 +344,18 @@ export type MarkdownProps = {
|
|
|
325
344
|
* Optional FlatList tuning for virtualization.
|
|
326
345
|
*/
|
|
327
346
|
virtualization?: MarkdownVirtualizationOptions;
|
|
347
|
+
/**
|
|
348
|
+
* Optional configuration for the table renderer.
|
|
349
|
+
*/
|
|
350
|
+
tableOptions?: {
|
|
351
|
+
minColumnWidth?: number;
|
|
352
|
+
measurementStabilizeMs?: number;
|
|
353
|
+
};
|
|
354
|
+
/**
|
|
355
|
+
* Enable built-in syntax highlighting for code blocks.
|
|
356
|
+
* Pass `true` to use the built-in tokenizer, or a custom highlighter function.
|
|
357
|
+
*/
|
|
358
|
+
highlightCode?: boolean | CodeHighlighter;
|
|
328
359
|
};
|
|
329
360
|
|
|
330
361
|
export const Markdown: FC<MarkdownProps> = ({
|
|
@@ -341,16 +372,19 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
341
372
|
onParsingInProgress,
|
|
342
373
|
onParseComplete,
|
|
343
374
|
onLinkPress,
|
|
375
|
+
onError,
|
|
344
376
|
virtualize = false,
|
|
345
377
|
virtualizationMinBlocks = 40,
|
|
346
378
|
virtualization,
|
|
379
|
+
tableOptions,
|
|
380
|
+
highlightCode,
|
|
347
381
|
}) => {
|
|
348
382
|
const parserOptionGfm = options?.gfm;
|
|
349
383
|
const parserOptionMath = options?.math;
|
|
350
384
|
|
|
351
385
|
const parseResult = useMemo(() => {
|
|
352
386
|
try {
|
|
353
|
-
const markdownToParse = applyBeforeParsePlugins(children, plugins);
|
|
387
|
+
const markdownToParse = applyBeforeParsePlugins(children, plugins, onError ? (e, phase, name) => onError(e, phase, name) : undefined);
|
|
354
388
|
const parserOptions = normalizeParserOptions({
|
|
355
389
|
gfm: parserOptionGfm,
|
|
356
390
|
math: parserOptionMath,
|
|
@@ -358,7 +392,7 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
358
392
|
let parsedAst = sourceAst
|
|
359
393
|
? cloneMarkdownNode(sourceAst)
|
|
360
394
|
: getCachedParsedAst(markdownToParse, parserOptions);
|
|
361
|
-
parsedAst = applyAfterParsePlugins(parsedAst, plugins);
|
|
395
|
+
parsedAst = applyAfterParsePlugins(parsedAst, plugins, onError ? (e, phase, name) => onError(e, phase, name) : undefined);
|
|
362
396
|
|
|
363
397
|
let ast = parsedAst;
|
|
364
398
|
if (astTransform) {
|
|
@@ -379,7 +413,8 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
379
413
|
return {
|
|
380
414
|
ast,
|
|
381
415
|
};
|
|
382
|
-
} catch {
|
|
416
|
+
} catch (parseError) {
|
|
417
|
+
onError?.(parseError instanceof Error ? parseError : new Error(String(parseError)), 'parse');
|
|
383
418
|
return {
|
|
384
419
|
ast: null,
|
|
385
420
|
};
|
|
@@ -391,6 +426,7 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
391
426
|
plugins,
|
|
392
427
|
sourceAst,
|
|
393
428
|
astTransform,
|
|
429
|
+
onError,
|
|
394
430
|
]);
|
|
395
431
|
|
|
396
432
|
useEffect(() => {
|
|
@@ -429,8 +465,10 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
429
465
|
styles: nodeStyles,
|
|
430
466
|
stylingStrategy,
|
|
431
467
|
onLinkPress,
|
|
468
|
+
tableOptions,
|
|
469
|
+
highlightCode,
|
|
432
470
|
}),
|
|
433
|
-
[renderers, theme, nodeStyles, stylingStrategy, onLinkPress],
|
|
471
|
+
[renderers, theme, nodeStyles, stylingStrategy, onLinkPress, tableOptions, highlightCode],
|
|
434
472
|
);
|
|
435
473
|
|
|
436
474
|
const topLevelBlocks =
|
|
@@ -644,7 +682,9 @@ const NodeRendererComponent: FC<NodeRendererProps> = ({
|
|
|
644
682
|
}
|
|
645
683
|
}
|
|
646
684
|
|
|
647
|
-
const nodeStyleOverride = nodeStyles?.[node.type]
|
|
685
|
+
const nodeStyleOverride = nodeStyles?.[node.type] as
|
|
686
|
+
| (ViewStyle & TextStyle)
|
|
687
|
+
| undefined;
|
|
648
688
|
|
|
649
689
|
switch (node.type) {
|
|
650
690
|
case "document":
|
package/src/renderers/code.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { getTextContent } from "../headless";
|
|
12
12
|
import { useMarkdownContext } from "../MarkdownContext";
|
|
13
13
|
import type { MarkdownNode } from "../headless";
|
|
14
|
+
import { defaultHighlighter, type HighlightedToken } from "../utils/code-highlight";
|
|
14
15
|
|
|
15
16
|
type CodeBlockProps = {
|
|
16
17
|
language?: string;
|
|
@@ -25,7 +26,14 @@ export const CodeBlock: FC<CodeBlockProps> = ({
|
|
|
25
26
|
node,
|
|
26
27
|
style,
|
|
27
28
|
}) => {
|
|
28
|
-
const
|
|
29
|
+
const ctx = useMarkdownContext();
|
|
30
|
+
const { theme } = ctx;
|
|
31
|
+
|
|
32
|
+
const highlighter = ctx.highlightCode === true
|
|
33
|
+
? defaultHighlighter
|
|
34
|
+
: typeof ctx.highlightCode === 'function'
|
|
35
|
+
? ctx.highlightCode
|
|
36
|
+
: null;
|
|
29
37
|
|
|
30
38
|
const displayContent = content ?? (node ? getTextContent(node) : "");
|
|
31
39
|
|
|
@@ -75,7 +83,24 @@ export const CodeBlock: FC<CodeBlockProps> = ({
|
|
|
75
83
|
showsHorizontalScrollIndicator={false}
|
|
76
84
|
bounces={false}
|
|
77
85
|
>
|
|
78
|
-
|
|
86
|
+
{highlighter && language ? (
|
|
87
|
+
<Text style={styles.codeBlockText} selectable>
|
|
88
|
+
{highlighter(language, displayContent).map((token: HighlightedToken, i: number) => {
|
|
89
|
+
const tokenColor = ctx.theme.colors.codeTokenColors?.[token.type];
|
|
90
|
+
return tokenColor ? (
|
|
91
|
+
<Text key={i} style={{ color: tokenColor }}>
|
|
92
|
+
{token.text}
|
|
93
|
+
</Text>
|
|
94
|
+
) : (
|
|
95
|
+
<Text key={i}>{token.text}</Text>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</Text>
|
|
99
|
+
) : (
|
|
100
|
+
<Text style={styles.codeBlockText} selectable>
|
|
101
|
+
{displayContent}
|
|
102
|
+
</Text>
|
|
103
|
+
)}
|
|
79
104
|
</ScrollView>
|
|
80
105
|
</View>
|
|
81
106
|
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type FC, type ComponentType } from "react";
|
|
2
|
+
import { View, Text, type StyleProp, type TextStyle } from "react-native";
|
|
3
|
+
import type { MarkdownNode } from "../../headless";
|
|
4
|
+
import type { NodeRendererProps } from "../../MarkdownContext";
|
|
5
|
+
|
|
6
|
+
type CellContentProps = {
|
|
7
|
+
node: MarkdownNode;
|
|
8
|
+
Renderer: ComponentType<NodeRendererProps>;
|
|
9
|
+
styles: {
|
|
10
|
+
cellContentWrapper: object;
|
|
11
|
+
};
|
|
12
|
+
textStyle?: StyleProp<TextStyle>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const CellContent: FC<CellContentProps> = ({
|
|
16
|
+
node,
|
|
17
|
+
Renderer,
|
|
18
|
+
styles,
|
|
19
|
+
textStyle,
|
|
20
|
+
}) => {
|
|
21
|
+
if (!node.children || node.children.length === 0) {
|
|
22
|
+
return <Text style={textStyle}>{node.content ?? ""}</Text>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<View style={styles.cellContentWrapper}>
|
|
27
|
+
{node.children.map((child, idx) => (
|
|
28
|
+
<Renderer
|
|
29
|
+
key={idx}
|
|
30
|
+
node={child}
|
|
31
|
+
depth={0}
|
|
32
|
+
inListItem={false}
|
|
33
|
+
parentIsText={false}
|
|
34
|
+
/>
|
|
35
|
+
))}
|
|
36
|
+
</View>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useReducer,
|
|
6
|
+
useCallback,
|
|
7
|
+
type FC,
|
|
8
|
+
type ComponentType,
|
|
9
|
+
} from "react";
|
|
10
|
+
import {
|
|
11
|
+
View,
|
|
12
|
+
StyleSheet,
|
|
13
|
+
ScrollView,
|
|
14
|
+
Platform,
|
|
15
|
+
type ViewStyle,
|
|
16
|
+
type LayoutChangeEvent,
|
|
17
|
+
} from "react-native";
|
|
18
|
+
import { useMarkdownContext, type NodeRendererProps } from "../../MarkdownContext";
|
|
19
|
+
import type { MarkdownTheme } from "../../theme";
|
|
20
|
+
import { CellContent } from "./cell-content";
|
|
21
|
+
import { extractTableData, estimateColumnWidths } from "./table-utils";
|
|
22
|
+
import {
|
|
23
|
+
columnWidthsReducer,
|
|
24
|
+
DEFAULT_MIN_COLUMN_WIDTH,
|
|
25
|
+
DEFAULT_MEASUREMENT_STABILIZE_MS,
|
|
26
|
+
} from "./table-reducer";
|
|
27
|
+
|
|
28
|
+
type TableRendererProps = {
|
|
29
|
+
node: import("../../headless").MarkdownNode;
|
|
30
|
+
Renderer: ComponentType<NodeRendererProps>;
|
|
31
|
+
style?: ViewStyle;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const COLUMN_MEASUREMENT_PADDING = 8;
|
|
35
|
+
|
|
36
|
+
const IS_ACT_TEST_ENVIRONMENT =
|
|
37
|
+
Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT") === true;
|
|
38
|
+
const SHOULD_DEBOUNCE_MEASUREMENT = !IS_ACT_TEST_ENVIRONMENT;
|
|
39
|
+
|
|
40
|
+
export const TableRenderer: FC<TableRendererProps> = ({
|
|
41
|
+
node,
|
|
42
|
+
Renderer,
|
|
43
|
+
style,
|
|
44
|
+
}) => {
|
|
45
|
+
const { theme, tableOptions } = useMarkdownContext();
|
|
46
|
+
|
|
47
|
+
const minColumnWidth =
|
|
48
|
+
tableOptions?.minColumnWidth ?? DEFAULT_MIN_COLUMN_WIDTH;
|
|
49
|
+
const measurementStabilizeMs =
|
|
50
|
+
tableOptions?.measurementStabilizeMs ?? DEFAULT_MEASUREMENT_STABILIZE_MS;
|
|
51
|
+
|
|
52
|
+
const { headers, rows, alignments } = useMemo(
|
|
53
|
+
() => extractTableData(node),
|
|
54
|
+
[node],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const columnCount = headers.length;
|
|
58
|
+
const styles = useMemo(() => createTableStyles(theme), [theme]);
|
|
59
|
+
const estimatedColumnWidths = useMemo(
|
|
60
|
+
() => estimateColumnWidths(headers, rows, columnCount, minColumnWidth),
|
|
61
|
+
[headers, rows, columnCount, minColumnWidth],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const [columnWidths, dispatch] = useReducer(
|
|
65
|
+
columnWidthsReducer,
|
|
66
|
+
estimatedColumnWidths,
|
|
67
|
+
);
|
|
68
|
+
const measuredWidths = useRef<Map<string, number>>(new Map());
|
|
69
|
+
const measuredCells = useRef<Set<string>>(new Set());
|
|
70
|
+
const widthsCalculated = useRef(false);
|
|
71
|
+
const columnWidthsRef = useRef(columnWidths);
|
|
72
|
+
const lastCellKeySignatureRef = useRef("");
|
|
73
|
+
const measurementTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
74
|
+
null,
|
|
75
|
+
);
|
|
76
|
+
const [needsMeasurement, setNeedsMeasurement] = useReducer(
|
|
77
|
+
(_previous: boolean, nextValue: boolean) => nextValue,
|
|
78
|
+
false,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const expectedCellKeys = useMemo(() => {
|
|
82
|
+
const keys: string[] = [];
|
|
83
|
+
|
|
84
|
+
headers.forEach((_, colIndex) => {
|
|
85
|
+
keys.push(`header-${colIndex}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
rows.forEach((row, rowIndex) => {
|
|
89
|
+
row.forEach((_, colIndex) => {
|
|
90
|
+
keys.push(`cell-${rowIndex}-${colIndex}`);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return keys;
|
|
95
|
+
}, [headers, rows]);
|
|
96
|
+
|
|
97
|
+
const expectedCellKeySignature = useMemo(
|
|
98
|
+
() => expectedCellKeys.join("|"),
|
|
99
|
+
[expectedCellKeys],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
columnWidthsRef.current = columnWidths;
|
|
104
|
+
}, [columnWidths]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const structureChanged =
|
|
108
|
+
lastCellKeySignatureRef.current !== expectedCellKeySignature;
|
|
109
|
+
lastCellKeySignatureRef.current = expectedCellKeySignature;
|
|
110
|
+
|
|
111
|
+
if (measurementTimerRef.current) {
|
|
112
|
+
clearTimeout(measurementTimerRef.current);
|
|
113
|
+
measurementTimerRef.current = null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (structureChanged) {
|
|
117
|
+
measuredWidths.current.clear();
|
|
118
|
+
measuredCells.current.clear();
|
|
119
|
+
widthsCalculated.current = false;
|
|
120
|
+
setNeedsMeasurement(false);
|
|
121
|
+
dispatch({ type: "RESET_WIDTHS", widths: estimatedColumnWidths });
|
|
122
|
+
} else {
|
|
123
|
+
dispatch({
|
|
124
|
+
type: "SET_MONOTONIC_WIDTHS",
|
|
125
|
+
widths: estimatedColumnWidths,
|
|
126
|
+
});
|
|
127
|
+
if (widthsCalculated.current) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!SHOULD_DEBOUNCE_MEASUREMENT) {
|
|
133
|
+
setNeedsMeasurement(true);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
measurementTimerRef.current = setTimeout(() => {
|
|
138
|
+
measurementTimerRef.current = null;
|
|
139
|
+
setNeedsMeasurement(true);
|
|
140
|
+
}, measurementStabilizeMs);
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
if (measurementTimerRef.current) {
|
|
144
|
+
clearTimeout(measurementTimerRef.current);
|
|
145
|
+
measurementTimerRef.current = null;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}, [estimatedColumnWidths, expectedCellKeySignature, measurementStabilizeMs]);
|
|
149
|
+
|
|
150
|
+
const onCellLayout = useCallback(
|
|
151
|
+
(cellKey: string, width: number) => {
|
|
152
|
+
if (width <= 0 || widthsCalculated.current || !needsMeasurement) return;
|
|
153
|
+
|
|
154
|
+
measuredWidths.current.set(cellKey, width);
|
|
155
|
+
if (!measuredCells.current.has(cellKey)) {
|
|
156
|
+
measuredCells.current.add(cellKey);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (measuredCells.current.size < expectedCellKeys.length) return;
|
|
160
|
+
|
|
161
|
+
const allCellsMeasured = expectedCellKeys.every((key) =>
|
|
162
|
+
measuredCells.current.has(key),
|
|
163
|
+
);
|
|
164
|
+
if (!allCellsMeasured) return;
|
|
165
|
+
|
|
166
|
+
const maxWidths: number[] = [...columnWidthsRef.current];
|
|
167
|
+
|
|
168
|
+
for (let col = 0; col < columnCount; col++) {
|
|
169
|
+
const headerWidth = measuredWidths.current.get(`header-${col}`);
|
|
170
|
+
if (headerWidth && headerWidth > 0) {
|
|
171
|
+
maxWidths[col] = Math.max(maxWidths[col], headerWidth);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (let row = 0; row < rows.length; row++) {
|
|
175
|
+
if (col >= rows[row].length) continue;
|
|
176
|
+
const cellWidth = measuredWidths.current.get(`cell-${row}-${col}`);
|
|
177
|
+
if (cellWidth && cellWidth > 0) {
|
|
178
|
+
maxWidths[col] = Math.max(maxWidths[col], cellWidth);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
maxWidths[col] = Math.max(
|
|
183
|
+
maxWidths[col] + COLUMN_MEASUREMENT_PADDING,
|
|
184
|
+
minColumnWidth,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
widthsCalculated.current = true;
|
|
189
|
+
setNeedsMeasurement(false);
|
|
190
|
+
dispatch({ type: "RESET_WIDTHS", widths: maxWidths });
|
|
191
|
+
},
|
|
192
|
+
[columnCount, expectedCellKeys, needsMeasurement, rows, minColumnWidth],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const getAlignment = (
|
|
196
|
+
index: number,
|
|
197
|
+
): "flex-start" | "center" | "flex-end" => {
|
|
198
|
+
const align = alignments[index];
|
|
199
|
+
if (align === "center") return "center";
|
|
200
|
+
if (align === "right") return "flex-end";
|
|
201
|
+
return "flex-start";
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (columnCount === 0) return null;
|
|
205
|
+
|
|
206
|
+
const hasWidths = columnWidths.length === columnCount;
|
|
207
|
+
const resolvedWidths = hasWidths ? columnWidths : estimatedColumnWidths;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<View style={[styles.container, style]}>
|
|
211
|
+
{needsMeasurement ? (
|
|
212
|
+
<View style={styles.measurementContainer}>
|
|
213
|
+
<View style={styles.measurementRow}>
|
|
214
|
+
{headers.map((cell, colIndex) => (
|
|
215
|
+
<View
|
|
216
|
+
key={`measure-header-${colIndex}`}
|
|
217
|
+
style={styles.measurementCell}
|
|
218
|
+
onLayout={(e: LayoutChangeEvent) => {
|
|
219
|
+
onCellLayout(
|
|
220
|
+
`header-${colIndex}`,
|
|
221
|
+
e.nativeEvent.layout.width,
|
|
222
|
+
);
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<CellContent node={cell} Renderer={Renderer} styles={styles} />
|
|
226
|
+
</View>
|
|
227
|
+
))}
|
|
228
|
+
</View>
|
|
229
|
+
{rows.map((row, rowIndex) => (
|
|
230
|
+
<View key={`measure-row-${rowIndex}`} style={styles.measurementRow}>
|
|
231
|
+
{row.map((cell, colIndex) => (
|
|
232
|
+
<View
|
|
233
|
+
key={`measure-cell-${rowIndex}-${colIndex}`}
|
|
234
|
+
style={styles.measurementCell}
|
|
235
|
+
onLayout={(e: LayoutChangeEvent) => {
|
|
236
|
+
onCellLayout(
|
|
237
|
+
`cell-${rowIndex}-${colIndex}`,
|
|
238
|
+
e.nativeEvent.layout.width,
|
|
239
|
+
);
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<CellContent
|
|
243
|
+
node={cell}
|
|
244
|
+
Renderer={Renderer}
|
|
245
|
+
styles={styles}
|
|
246
|
+
/>
|
|
247
|
+
</View>
|
|
248
|
+
))}
|
|
249
|
+
</View>
|
|
250
|
+
))}
|
|
251
|
+
</View>
|
|
252
|
+
) : null}
|
|
253
|
+
|
|
254
|
+
<ScrollView
|
|
255
|
+
horizontal
|
|
256
|
+
showsHorizontalScrollIndicator
|
|
257
|
+
style={styles.tableScroll}
|
|
258
|
+
bounces={false}
|
|
259
|
+
>
|
|
260
|
+
<View
|
|
261
|
+
style={[
|
|
262
|
+
styles.table,
|
|
263
|
+
{
|
|
264
|
+
backgroundColor:
|
|
265
|
+
style?.backgroundColor ?? theme.colors.surface ?? "#111827",
|
|
266
|
+
},
|
|
267
|
+
]}
|
|
268
|
+
>
|
|
269
|
+
<View style={styles.headerRow}>
|
|
270
|
+
{headers.map((cell, colIndex) => (
|
|
271
|
+
<View
|
|
272
|
+
key={`header-${colIndex}`}
|
|
273
|
+
style={[
|
|
274
|
+
styles.headerCell,
|
|
275
|
+
{
|
|
276
|
+
width: resolvedWidths[colIndex] ?? minColumnWidth,
|
|
277
|
+
alignItems: getAlignment(colIndex),
|
|
278
|
+
},
|
|
279
|
+
colIndex === columnCount - 1 && styles.lastCell,
|
|
280
|
+
]}
|
|
281
|
+
>
|
|
282
|
+
<CellContent
|
|
283
|
+
node={cell}
|
|
284
|
+
Renderer={Renderer}
|
|
285
|
+
styles={styles}
|
|
286
|
+
textStyle={styles.headerText}
|
|
287
|
+
/>
|
|
288
|
+
</View>
|
|
289
|
+
))}
|
|
290
|
+
</View>
|
|
291
|
+
|
|
292
|
+
{rows.map((row, rowIndex) => (
|
|
293
|
+
<View
|
|
294
|
+
key={`row-${rowIndex}`}
|
|
295
|
+
style={[
|
|
296
|
+
styles.bodyRow,
|
|
297
|
+
rowIndex === rows.length - 1 && styles.lastRow,
|
|
298
|
+
rowIndex % 2 === 0 ? styles.evenRow : styles.oddRow,
|
|
299
|
+
]}
|
|
300
|
+
>
|
|
301
|
+
{row.map((cell, colIndex) => (
|
|
302
|
+
<View
|
|
303
|
+
key={`cell-${rowIndex}-${colIndex}`}
|
|
304
|
+
style={[
|
|
305
|
+
styles.bodyCell,
|
|
306
|
+
{
|
|
307
|
+
width: resolvedWidths[colIndex] ?? minColumnWidth,
|
|
308
|
+
alignItems: getAlignment(colIndex),
|
|
309
|
+
},
|
|
310
|
+
colIndex === columnCount - 1 && styles.lastCell,
|
|
311
|
+
]}
|
|
312
|
+
>
|
|
313
|
+
<CellContent
|
|
314
|
+
node={cell}
|
|
315
|
+
Renderer={Renderer}
|
|
316
|
+
styles={styles}
|
|
317
|
+
textStyle={styles.cellText}
|
|
318
|
+
/>
|
|
319
|
+
</View>
|
|
320
|
+
))}
|
|
321
|
+
</View>
|
|
322
|
+
))}
|
|
323
|
+
</View>
|
|
324
|
+
</ScrollView>
|
|
325
|
+
</View>
|
|
326
|
+
);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const createTableStyles = (theme: MarkdownTheme) => {
|
|
330
|
+
const colors = theme?.colors || {};
|
|
331
|
+
const borderRadius = theme?.borderRadius || { m: 8 };
|
|
332
|
+
|
|
333
|
+
return StyleSheet.create({
|
|
334
|
+
container: {
|
|
335
|
+
marginVertical: theme.spacing.s,
|
|
336
|
+
},
|
|
337
|
+
measurementContainer: {
|
|
338
|
+
position: "absolute",
|
|
339
|
+
opacity: 0,
|
|
340
|
+
pointerEvents: "none",
|
|
341
|
+
left: -9999,
|
|
342
|
+
},
|
|
343
|
+
measurementRow: {
|
|
344
|
+
flexDirection: "row",
|
|
345
|
+
},
|
|
346
|
+
measurementCell: {
|
|
347
|
+
paddingVertical: 10,
|
|
348
|
+
paddingHorizontal: 12,
|
|
349
|
+
},
|
|
350
|
+
tableScroll: {
|
|
351
|
+
flexGrow: 0,
|
|
352
|
+
},
|
|
353
|
+
table: {
|
|
354
|
+
borderRadius: borderRadius.m,
|
|
355
|
+
overflow: "hidden",
|
|
356
|
+
borderWidth: 1,
|
|
357
|
+
borderColor: colors.tableBorder || "#374151",
|
|
358
|
+
},
|
|
359
|
+
headerRow: {
|
|
360
|
+
flexDirection: "row",
|
|
361
|
+
backgroundColor: colors.tableHeader || "#1f2937",
|
|
362
|
+
borderBottomWidth: 1,
|
|
363
|
+
borderBottomColor: colors.tableBorder || "#374151",
|
|
364
|
+
},
|
|
365
|
+
bodyRow: {
|
|
366
|
+
flexDirection: "row",
|
|
367
|
+
borderBottomWidth: 1,
|
|
368
|
+
borderBottomColor: colors.tableBorder || "#374151",
|
|
369
|
+
},
|
|
370
|
+
evenRow: {
|
|
371
|
+
backgroundColor: colors.tableRowEven || "transparent",
|
|
372
|
+
},
|
|
373
|
+
oddRow: {
|
|
374
|
+
backgroundColor: colors.tableRowOdd || "rgba(255,255,255,0.02)",
|
|
375
|
+
},
|
|
376
|
+
lastRow: {
|
|
377
|
+
borderBottomWidth: 0,
|
|
378
|
+
},
|
|
379
|
+
headerCell: {
|
|
380
|
+
flexShrink: 0,
|
|
381
|
+
paddingVertical: 10,
|
|
382
|
+
paddingHorizontal: 12,
|
|
383
|
+
minWidth: 60,
|
|
384
|
+
borderRightWidth: 1,
|
|
385
|
+
borderRightColor: colors.tableBorder || "#374151",
|
|
386
|
+
},
|
|
387
|
+
bodyCell: {
|
|
388
|
+
flexShrink: 0,
|
|
389
|
+
paddingVertical: 10,
|
|
390
|
+
paddingHorizontal: 12,
|
|
391
|
+
minWidth: 60,
|
|
392
|
+
borderRightWidth: 1,
|
|
393
|
+
borderRightColor: colors.tableBorder || "#374151",
|
|
394
|
+
justifyContent: "center",
|
|
395
|
+
},
|
|
396
|
+
lastCell: {
|
|
397
|
+
borderRightWidth: 0,
|
|
398
|
+
},
|
|
399
|
+
headerText: {
|
|
400
|
+
color: colors.tableHeaderText || "#9ca3af",
|
|
401
|
+
fontSize: 12,
|
|
402
|
+
fontWeight: "600",
|
|
403
|
+
fontFamily: theme.fontFamilies?.regular,
|
|
404
|
+
...(Platform.OS === "android" && { includeFontPadding: false }),
|
|
405
|
+
},
|
|
406
|
+
cellText: {
|
|
407
|
+
color: colors.text || "#e5e7eb",
|
|
408
|
+
fontSize: 14,
|
|
409
|
+
lineHeight: 20,
|
|
410
|
+
fontFamily: theme.fontFamilies?.regular,
|
|
411
|
+
...(Platform.OS === "android" && { includeFontPadding: false }),
|
|
412
|
+
},
|
|
413
|
+
cellContentWrapper: {
|
|
414
|
+
flexDirection: "row",
|
|
415
|
+
flexWrap: "wrap",
|
|
416
|
+
alignItems: "center",
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
};
|