react-native-nitro-markdown 0.4.3 → 0.5.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/README.md +351 -22
- package/android/src/main/java/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSession.kt +27 -8
- package/cpp/bindings/HybridMarkdownParser.cpp +216 -66
- package/cpp/bindings/HybridMarkdownParser.hpp +2 -0
- package/ios/HybridMarkdownSession.swift +33 -7
- package/lib/commonjs/headless.js +41 -5
- package/lib/commonjs/headless.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/markdown-stream.js +107 -13
- package/lib/commonjs/markdown-stream.js.map +1 -1
- package/lib/commonjs/markdown.js +180 -25
- package/lib/commonjs/markdown.js.map +1 -1
- package/lib/commonjs/renderers/code.js +1 -0
- package/lib/commonjs/renderers/code.js.map +1 -1
- package/lib/commonjs/renderers/table.js +116 -24
- package/lib/commonjs/renderers/table.js.map +1 -1
- package/lib/commonjs/utils/incremental-ast.js +153 -0
- package/lib/commonjs/utils/incremental-ast.js.map +1 -0
- package/lib/module/headless.js +37 -4
- package/lib/module/headless.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/markdown-stream.js +108 -14
- package/lib/module/markdown-stream.js.map +1 -1
- package/lib/module/markdown.js +182 -27
- package/lib/module/markdown.js.map +1 -1
- package/lib/module/renderers/code.js +1 -0
- package/lib/module/renderers/code.js.map +1 -1
- package/lib/module/renderers/table.js +116 -24
- package/lib/module/renderers/table.js.map +1 -1
- package/lib/module/utils/incremental-ast.js +147 -0
- package/lib/module/utils/incremental-ast.js.map +1 -0
- package/lib/typescript/commonjs/Markdown.nitro.d.ts +2 -0
- package/lib/typescript/commonjs/Markdown.nitro.d.ts.map +1 -1
- package/lib/typescript/commonjs/headless.d.ts +13 -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-stream.d.ts +6 -1
- package/lib/typescript/commonjs/markdown-stream.d.ts.map +1 -1
- package/lib/typescript/commonjs/markdown.d.ts +53 -1
- 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.d.ts +1 -1
- package/lib/typescript/commonjs/renderers/table.d.ts.map +1 -1
- package/lib/typescript/commonjs/specs/MarkdownSession.nitro.d.ts +5 -2
- package/lib/typescript/commonjs/specs/MarkdownSession.nitro.d.ts.map +1 -1
- package/lib/typescript/commonjs/utils/incremental-ast.d.ts +12 -0
- package/lib/typescript/commonjs/utils/incremental-ast.d.ts.map +1 -0
- package/lib/typescript/module/Markdown.nitro.d.ts +2 -0
- package/lib/typescript/module/Markdown.nitro.d.ts.map +1 -1
- package/lib/typescript/module/headless.d.ts +13 -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-stream.d.ts +6 -1
- package/lib/typescript/module/markdown-stream.d.ts.map +1 -1
- package/lib/typescript/module/markdown.d.ts +53 -1
- 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.d.ts +1 -1
- package/lib/typescript/module/renderers/table.d.ts.map +1 -1
- package/lib/typescript/module/specs/MarkdownSession.nitro.d.ts +5 -2
- package/lib/typescript/module/specs/MarkdownSession.nitro.d.ts.map +1 -1
- package/lib/typescript/module/utils/incremental-ast.d.ts +12 -0
- package/lib/typescript/module/utils/incremental-ast.d.ts.map +1 -0
- package/nitrogen/generated/android/NitroMarkdownOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridMarkdownSessionSpec.cpp +18 -6
- package/nitrogen/generated/android/c++/JHybridMarkdownSessionSpec.hpp +4 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/com/nitromarkdown/Func_void_double_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSessionSpec.kt +11 -3
- package/nitrogen/generated/ios/NitroMarkdown-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/NitroMarkdown-Swift-Cxx-Bridge.hpp +31 -0
- package/nitrogen/generated/ios/c++/HybridMarkdownSessionSpecSwift.hpp +20 -2
- package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMarkdownSessionSpec.swift +4 -2
- package/nitrogen/generated/ios/swift/HybridMarkdownSessionSpec_cxx.swift +34 -9
- package/nitrogen/generated/shared/c++/HybridMarkdownParserSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMarkdownParserSpec.hpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMarkdownSessionSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMarkdownSessionSpec.hpp +4 -2
- package/package.json +4 -3
- package/src/Markdown.nitro.ts +2 -0
- package/src/headless.ts +42 -4
- package/src/index.ts +7 -0
- package/src/markdown-stream.tsx +163 -15
- package/src/markdown.tsx +339 -24
- package/src/renderers/code.tsx +5 -1
- package/src/renderers/table.tsx +212 -66
- package/src/specs/MarkdownSession.nitro.ts +6 -2
- package/src/utils/incremental-ast.ts +224 -0
package/src/markdown-stream.tsx
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
startTransition,
|
|
7
|
+
type FC,
|
|
8
|
+
} from "react";
|
|
9
|
+
import type { MarkdownNode } from "./headless";
|
|
2
10
|
import { Markdown, type MarkdownProps } from "./markdown";
|
|
3
11
|
import type { MarkdownSession } from "./specs/MarkdownSession.nitro";
|
|
12
|
+
import { getNextStreamAst, parseMarkdownAst } from "./utils/incremental-ast";
|
|
13
|
+
|
|
14
|
+
const normalizeOffset = (value: number): number | null => {
|
|
15
|
+
if (!Number.isFinite(value)) return null;
|
|
16
|
+
if (value <= 0) return 0;
|
|
17
|
+
return Math.floor(value);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const resolveStreamText = ({
|
|
21
|
+
forceFullSync,
|
|
22
|
+
pendingFrom,
|
|
23
|
+
pendingTo,
|
|
24
|
+
previousText,
|
|
25
|
+
session,
|
|
26
|
+
}: {
|
|
27
|
+
forceFullSync: boolean;
|
|
28
|
+
pendingFrom: number | null;
|
|
29
|
+
pendingTo: number | null;
|
|
30
|
+
previousText: string;
|
|
31
|
+
session: MarkdownSession;
|
|
32
|
+
}): string => {
|
|
33
|
+
if (forceFullSync || pendingFrom === null || pendingTo === null) {
|
|
34
|
+
return session.getAllText();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (pendingTo < pendingFrom) {
|
|
38
|
+
return session.getAllText();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (pendingFrom === previousText.length) {
|
|
42
|
+
const appendedChunk = session.getTextRange(pendingFrom, pendingTo);
|
|
43
|
+
return `${previousText}${appendedChunk}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (pendingFrom === 0) {
|
|
47
|
+
return session.getTextRange(0, pendingTo);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return session.getAllText();
|
|
51
|
+
};
|
|
4
52
|
|
|
5
53
|
export type MarkdownStreamProps = {
|
|
6
54
|
/**
|
|
@@ -23,7 +71,12 @@ export type MarkdownStreamProps = {
|
|
|
23
71
|
* Useful when you want to prioritize user interactions over stream renders.
|
|
24
72
|
*/
|
|
25
73
|
useTransitionUpdates?: boolean;
|
|
26
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Enable incremental AST updates for append-only streams.
|
|
76
|
+
* Automatically falls back to full parse when updates are not safely mergeable.
|
|
77
|
+
*/
|
|
78
|
+
incrementalParsing?: boolean;
|
|
79
|
+
} & Omit<MarkdownProps, "children" | "sourceAst">;
|
|
27
80
|
|
|
28
81
|
/**
|
|
29
82
|
* A component that renders streaming Markdown from a MarkdownSession.
|
|
@@ -34,20 +87,55 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
|
|
|
34
87
|
updateIntervalMs = 50,
|
|
35
88
|
updateStrategy = "interval",
|
|
36
89
|
useTransitionUpdates = false,
|
|
90
|
+
incrementalParsing = true,
|
|
91
|
+
options,
|
|
92
|
+
plugins,
|
|
37
93
|
...props
|
|
38
94
|
}) => {
|
|
39
|
-
const
|
|
95
|
+
const parseText = useCallback(
|
|
96
|
+
(text: string): MarkdownNode => parseMarkdownAst(text, options),
|
|
97
|
+
[options],
|
|
98
|
+
);
|
|
99
|
+
const createEmptyAst = (): MarkdownNode => ({
|
|
100
|
+
type: "document",
|
|
101
|
+
children: [],
|
|
102
|
+
});
|
|
103
|
+
const initialText = session.getAllText();
|
|
104
|
+
const hasBeforeParsePlugins =
|
|
105
|
+
plugins?.some((plugin) => typeof plugin.beforeParse === "function") ??
|
|
106
|
+
false;
|
|
107
|
+
const [renderState, setRenderState] = useState(() => ({
|
|
108
|
+
text: initialText,
|
|
109
|
+
ast: hasBeforeParsePlugins ? createEmptyAst() : parseText(initialText),
|
|
110
|
+
}));
|
|
111
|
+
const renderStateRef = useRef(renderState);
|
|
40
112
|
const pendingUpdateRef = useRef(false);
|
|
113
|
+
const pendingFromRef = useRef<number | null>(null);
|
|
114
|
+
const pendingToRef = useRef<number | null>(null);
|
|
115
|
+
const forceFullSyncRef = useRef(false);
|
|
41
116
|
const updateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
42
117
|
const rafRef = useRef<number | null>(null);
|
|
43
|
-
const
|
|
118
|
+
const allowIncremental = incrementalParsing && !hasBeforeParsePlugins;
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
renderStateRef.current = renderState;
|
|
122
|
+
}, [renderState]);
|
|
44
123
|
|
|
45
124
|
useEffect(() => {
|
|
46
|
-
// Ensure initial state is synced
|
|
47
125
|
const initialText = session.getAllText();
|
|
48
|
-
|
|
49
|
-
|
|
126
|
+
const initialState = {
|
|
127
|
+
text: initialText,
|
|
128
|
+
ast: hasBeforeParsePlugins ? createEmptyAst() : parseText(initialText),
|
|
129
|
+
};
|
|
130
|
+
pendingUpdateRef.current = false;
|
|
131
|
+
pendingFromRef.current = null;
|
|
132
|
+
pendingToRef.current = null;
|
|
133
|
+
forceFullSyncRef.current = false;
|
|
134
|
+
renderStateRef.current = initialState;
|
|
135
|
+
setRenderState(initialState);
|
|
136
|
+
}, [hasBeforeParsePlugins, parseText, session]);
|
|
50
137
|
|
|
138
|
+
useEffect(() => {
|
|
51
139
|
const flushUpdate = () => {
|
|
52
140
|
updateTimerRef.current = null;
|
|
53
141
|
if (rafRef.current !== null) {
|
|
@@ -57,16 +145,44 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
|
|
|
57
145
|
if (!pendingUpdateRef.current) return;
|
|
58
146
|
pendingUpdateRef.current = false;
|
|
59
147
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
148
|
+
const previousState = renderStateRef.current;
|
|
149
|
+
const pendingFrom = pendingFromRef.current;
|
|
150
|
+
const pendingTo = pendingToRef.current;
|
|
151
|
+
const forceFullSync = forceFullSyncRef.current;
|
|
152
|
+
pendingFromRef.current = null;
|
|
153
|
+
pendingToRef.current = null;
|
|
154
|
+
forceFullSyncRef.current = false;
|
|
155
|
+
|
|
156
|
+
const latest = resolveStreamText({
|
|
157
|
+
forceFullSync,
|
|
158
|
+
pendingFrom,
|
|
159
|
+
pendingTo,
|
|
160
|
+
previousText: previousState.text,
|
|
161
|
+
session,
|
|
162
|
+
});
|
|
163
|
+
if (latest === previousState.text) return;
|
|
164
|
+
|
|
165
|
+
const nextAst = hasBeforeParsePlugins
|
|
166
|
+
? previousState.ast
|
|
167
|
+
: getNextStreamAst({
|
|
168
|
+
allowIncremental,
|
|
169
|
+
nextText: latest,
|
|
170
|
+
options,
|
|
171
|
+
previousAst: previousState.ast,
|
|
172
|
+
previousText: previousState.text,
|
|
173
|
+
});
|
|
174
|
+
const nextState = {
|
|
175
|
+
text: latest,
|
|
176
|
+
ast: nextAst,
|
|
177
|
+
};
|
|
178
|
+
renderStateRef.current = nextState;
|
|
63
179
|
|
|
64
180
|
if (useTransitionUpdates) {
|
|
65
181
|
startTransition(() => {
|
|
66
|
-
|
|
182
|
+
setRenderState(nextState);
|
|
67
183
|
});
|
|
68
184
|
} else {
|
|
69
|
-
|
|
185
|
+
setRenderState(nextState);
|
|
70
186
|
}
|
|
71
187
|
};
|
|
72
188
|
|
|
@@ -83,7 +199,22 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
|
|
|
83
199
|
}
|
|
84
200
|
};
|
|
85
201
|
|
|
86
|
-
const unsubscribe = session.addListener(() => {
|
|
202
|
+
const unsubscribe = session.addListener((from, to) => {
|
|
203
|
+
const nextFrom = normalizeOffset(from);
|
|
204
|
+
const nextTo = normalizeOffset(to);
|
|
205
|
+
|
|
206
|
+
if (nextFrom === null || nextTo === null || nextTo < nextFrom) {
|
|
207
|
+
forceFullSyncRef.current = true;
|
|
208
|
+
} else {
|
|
209
|
+
const currentFrom = pendingFromRef.current;
|
|
210
|
+
const currentTo = pendingToRef.current;
|
|
211
|
+
|
|
212
|
+
pendingFromRef.current =
|
|
213
|
+
currentFrom === null ? nextFrom : Math.min(currentFrom, nextFrom);
|
|
214
|
+
pendingToRef.current =
|
|
215
|
+
currentTo === null ? nextTo : Math.max(currentTo, nextTo);
|
|
216
|
+
}
|
|
217
|
+
|
|
87
218
|
pendingUpdateRef.current = true;
|
|
88
219
|
scheduleFlush();
|
|
89
220
|
});
|
|
@@ -99,7 +230,24 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
|
|
|
99
230
|
rafRef.current = null;
|
|
100
231
|
}
|
|
101
232
|
};
|
|
102
|
-
}, [
|
|
233
|
+
}, [
|
|
234
|
+
allowIncremental,
|
|
235
|
+
hasBeforeParsePlugins,
|
|
236
|
+
options,
|
|
237
|
+
session,
|
|
238
|
+
updateIntervalMs,
|
|
239
|
+
updateStrategy,
|
|
240
|
+
useTransitionUpdates,
|
|
241
|
+
]);
|
|
103
242
|
|
|
104
|
-
return
|
|
243
|
+
return (
|
|
244
|
+
<Markdown
|
|
245
|
+
{...props}
|
|
246
|
+
options={options}
|
|
247
|
+
plugins={plugins}
|
|
248
|
+
sourceAst={hasBeforeParsePlugins ? undefined : renderState.ast}
|
|
249
|
+
>
|
|
250
|
+
{renderState.text}
|
|
251
|
+
</Markdown>
|
|
252
|
+
);
|
|
105
253
|
};
|
package/src/markdown.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
memo,
|
|
3
|
+
useCallback,
|
|
2
4
|
useEffect,
|
|
3
5
|
useMemo,
|
|
4
6
|
type FC,
|
|
@@ -10,7 +12,10 @@ import {
|
|
|
10
12
|
StyleSheet,
|
|
11
13
|
View,
|
|
12
14
|
Text,
|
|
15
|
+
FlatList,
|
|
13
16
|
Platform,
|
|
17
|
+
type ListRenderItemInfo,
|
|
18
|
+
type FlatListProps,
|
|
14
19
|
type StyleProp,
|
|
15
20
|
type ViewStyle,
|
|
16
21
|
} from "react-native";
|
|
@@ -50,6 +55,188 @@ import {
|
|
|
50
55
|
} from "./theme";
|
|
51
56
|
|
|
52
57
|
const baseStylesCache = new WeakMap<MarkdownTheme, BaseStyles>();
|
|
58
|
+
const parseAstCache = new Map<string, MarkdownNode>();
|
|
59
|
+
const MAX_PARSE_CACHE_ENTRIES = 32;
|
|
60
|
+
const MAX_CACHEABLE_TEXT_LENGTH = 24_000;
|
|
61
|
+
|
|
62
|
+
export type AstTransform = (ast: MarkdownNode) => MarkdownNode;
|
|
63
|
+
export type MarkdownVirtualizationOptions = Pick<
|
|
64
|
+
FlatListProps<MarkdownNode>,
|
|
65
|
+
| "initialNumToRender"
|
|
66
|
+
| "maxToRenderPerBatch"
|
|
67
|
+
| "windowSize"
|
|
68
|
+
| "updateCellsBatchingPeriod"
|
|
69
|
+
| "removeClippedSubviews"
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
export type MarkdownPlugin = {
|
|
73
|
+
/**
|
|
74
|
+
* Optional plugin name used for diagnostics and debugging.
|
|
75
|
+
*/
|
|
76
|
+
name?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Optional plugin version metadata for diagnostics.
|
|
79
|
+
*/
|
|
80
|
+
version?: string | number;
|
|
81
|
+
/**
|
|
82
|
+
* Optional text preprocessor executed before native parsing.
|
|
83
|
+
* Should return a full markdown string.
|
|
84
|
+
*/
|
|
85
|
+
beforeParse?: (markdown: string) => string;
|
|
86
|
+
/**
|
|
87
|
+
* Optional AST postprocessor executed after native parsing.
|
|
88
|
+
*/
|
|
89
|
+
afterParse?: AstTransform;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const isMarkdownNode = (value: unknown): value is MarkdownNode => {
|
|
93
|
+
if (typeof value !== "object" || value === null) return false;
|
|
94
|
+
return typeof Reflect.get(value, "type") === "string";
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const warnInDev = (message: string, error?: unknown): void => {
|
|
98
|
+
if (typeof __DEV__ === "undefined" || !__DEV__) return;
|
|
99
|
+
|
|
100
|
+
const runtimeConsole = Reflect.get(globalThis, "console");
|
|
101
|
+
if (
|
|
102
|
+
typeof runtimeConsole === "object" &&
|
|
103
|
+
runtimeConsole !== null &&
|
|
104
|
+
"warn" in runtimeConsole &&
|
|
105
|
+
typeof runtimeConsole.warn === "function"
|
|
106
|
+
) {
|
|
107
|
+
runtimeConsole.warn(message, error);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const cloneMarkdownNode = (node: MarkdownNode): MarkdownNode => {
|
|
112
|
+
return {
|
|
113
|
+
...node,
|
|
114
|
+
children: node.children?.map(cloneMarkdownNode),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const getParserOptionsKey = (options?: ParserOptions): string => {
|
|
119
|
+
if (!options) return "gfm:default|math:default";
|
|
120
|
+
|
|
121
|
+
const gfm = options.gfm === undefined ? "default" : options.gfm ? "1" : "0";
|
|
122
|
+
const math =
|
|
123
|
+
options.math === undefined ? "default" : options.math ? "1" : "0";
|
|
124
|
+
return `gfm:${gfm}|math:${math}`;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const normalizeParserOptions = (
|
|
128
|
+
options?: ParserOptions,
|
|
129
|
+
): ParserOptions | undefined => {
|
|
130
|
+
if (!options) return undefined;
|
|
131
|
+
|
|
132
|
+
const gfm = options.gfm;
|
|
133
|
+
const math = options.math;
|
|
134
|
+
|
|
135
|
+
if (gfm === undefined && math === undefined) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
gfm,
|
|
141
|
+
math,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const parseWithNativeParser = (
|
|
146
|
+
text: string,
|
|
147
|
+
options?: ParserOptions,
|
|
148
|
+
): MarkdownNode => {
|
|
149
|
+
if (options) {
|
|
150
|
+
return parseMarkdownWithOptions(text, options);
|
|
151
|
+
}
|
|
152
|
+
return parseMarkdown(text);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const getCachedParsedAst = (
|
|
156
|
+
text: string,
|
|
157
|
+
options?: ParserOptions,
|
|
158
|
+
): MarkdownNode => {
|
|
159
|
+
if (text.length > MAX_CACHEABLE_TEXT_LENGTH) {
|
|
160
|
+
return parseWithNativeParser(text, options);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const cacheKey = `${getParserOptionsKey(options)}|${text}`;
|
|
164
|
+
const cachedNode = parseAstCache.get(cacheKey);
|
|
165
|
+
if (cachedNode) {
|
|
166
|
+
parseAstCache.delete(cacheKey);
|
|
167
|
+
parseAstCache.set(cacheKey, cachedNode);
|
|
168
|
+
return cloneMarkdownNode(cachedNode);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const parsedNode = parseWithNativeParser(text, options);
|
|
172
|
+
parseAstCache.set(cacheKey, parsedNode);
|
|
173
|
+
if (parseAstCache.size > MAX_PARSE_CACHE_ENTRIES) {
|
|
174
|
+
const oldestCacheKey = parseAstCache.keys().next().value;
|
|
175
|
+
if (typeof oldestCacheKey === "string") {
|
|
176
|
+
parseAstCache.delete(oldestCacheKey);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return cloneMarkdownNode(parsedNode);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const applyBeforeParsePlugins = (
|
|
184
|
+
markdown: string,
|
|
185
|
+
plugins?: MarkdownPlugin[],
|
|
186
|
+
): string => {
|
|
187
|
+
if (!plugins || plugins.length === 0) {
|
|
188
|
+
return markdown;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let nextMarkdown = markdown;
|
|
192
|
+
for (const plugin of plugins) {
|
|
193
|
+
if (!plugin.beforeParse) continue;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const transformed = plugin.beforeParse(nextMarkdown);
|
|
197
|
+
if (typeof transformed === "string") {
|
|
198
|
+
nextMarkdown = transformed;
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const pluginLabel = plugin.name ? ` (${plugin.name})` : "";
|
|
202
|
+
warnInDev(
|
|
203
|
+
`[react-native-nitro-markdown] plugin beforeParse${pluginLabel} threw; using previous markdown.`,
|
|
204
|
+
error,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return nextMarkdown;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const applyAfterParsePlugins = (
|
|
213
|
+
ast: MarkdownNode,
|
|
214
|
+
plugins?: MarkdownPlugin[],
|
|
215
|
+
): MarkdownNode => {
|
|
216
|
+
if (!plugins || plugins.length === 0) {
|
|
217
|
+
return ast;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let nextAst = ast;
|
|
221
|
+
for (const plugin of plugins) {
|
|
222
|
+
if (!plugin.afterParse) continue;
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const transformed = plugin.afterParse(nextAst);
|
|
226
|
+
if (isMarkdownNode(transformed)) {
|
|
227
|
+
nextAst = transformed;
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const pluginLabel = plugin.name ? ` (${plugin.name})` : "";
|
|
231
|
+
warnInDev(
|
|
232
|
+
`[react-native-nitro-markdown] plugin afterParse${pluginLabel} threw; using previous AST.`,
|
|
233
|
+
error,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return nextAst;
|
|
239
|
+
};
|
|
53
240
|
|
|
54
241
|
export type MarkdownProps = {
|
|
55
242
|
/**
|
|
@@ -60,6 +247,20 @@ export type MarkdownProps = {
|
|
|
60
247
|
* Parser options to enable GFM or Math support.
|
|
61
248
|
*/
|
|
62
249
|
options?: ParserOptions;
|
|
250
|
+
/**
|
|
251
|
+
* Optional parser plugins for preprocessing and AST postprocessing.
|
|
252
|
+
*/
|
|
253
|
+
plugins?: MarkdownPlugin[];
|
|
254
|
+
/**
|
|
255
|
+
* Optional pre-parsed AST.
|
|
256
|
+
* When provided, native parse is skipped and this tree is rendered instead.
|
|
257
|
+
*/
|
|
258
|
+
sourceAst?: MarkdownNode;
|
|
259
|
+
/**
|
|
260
|
+
* Optional transform applied after parsing and before rendering.
|
|
261
|
+
* The transformed AST is also returned in `onParseComplete`.
|
|
262
|
+
*/
|
|
263
|
+
astTransform?: AstTransform;
|
|
63
264
|
/**
|
|
64
265
|
* Callback fired when parsing begins.
|
|
65
266
|
*/
|
|
@@ -107,11 +308,31 @@ export type MarkdownProps = {
|
|
|
107
308
|
* Return false to prevent the default openURL behavior.
|
|
108
309
|
*/
|
|
109
310
|
onLinkPress?: LinkPressHandler;
|
|
311
|
+
/**
|
|
312
|
+
* Enables top-level block virtualization for very large markdown documents.
|
|
313
|
+
* Best used when Markdown is the primary scroll container on screen.
|
|
314
|
+
* - `true`: always virtualize when block threshold is met
|
|
315
|
+
* - `"auto"`: virtualize only when threshold is met (recommended for large docs)
|
|
316
|
+
* - `false`: disable virtualization (default)
|
|
317
|
+
*/
|
|
318
|
+
virtualize?: boolean | "auto";
|
|
319
|
+
/**
|
|
320
|
+
* Minimum number of top-level blocks before virtualization is activated.
|
|
321
|
+
* Helps avoid FlatList overhead on small documents.
|
|
322
|
+
*/
|
|
323
|
+
virtualizationMinBlocks?: number;
|
|
324
|
+
/**
|
|
325
|
+
* Optional FlatList tuning for virtualization.
|
|
326
|
+
*/
|
|
327
|
+
virtualization?: MarkdownVirtualizationOptions;
|
|
110
328
|
};
|
|
111
329
|
|
|
112
330
|
export const Markdown: FC<MarkdownProps> = ({
|
|
113
331
|
children,
|
|
114
332
|
options,
|
|
333
|
+
plugins,
|
|
334
|
+
sourceAst,
|
|
335
|
+
astTransform,
|
|
115
336
|
renderers = {},
|
|
116
337
|
theme: userTheme,
|
|
117
338
|
styles: nodeStyles,
|
|
@@ -120,41 +341,77 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
120
341
|
onParsingInProgress,
|
|
121
342
|
onParseComplete,
|
|
122
343
|
onLinkPress,
|
|
344
|
+
virtualize = false,
|
|
345
|
+
virtualizationMinBlocks = 40,
|
|
346
|
+
virtualization,
|
|
123
347
|
}) => {
|
|
348
|
+
const parserOptionGfm = options?.gfm;
|
|
349
|
+
const parserOptionMath = options?.math;
|
|
350
|
+
|
|
124
351
|
const parseResult = useMemo(() => {
|
|
125
352
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
353
|
+
const markdownToParse = applyBeforeParsePlugins(children, plugins);
|
|
354
|
+
const parserOptions = normalizeParserOptions({
|
|
355
|
+
gfm: parserOptionGfm,
|
|
356
|
+
math: parserOptionMath,
|
|
357
|
+
});
|
|
358
|
+
let parsedAst = sourceAst
|
|
359
|
+
? cloneMarkdownNode(sourceAst)
|
|
360
|
+
: getCachedParsedAst(markdownToParse, parserOptions);
|
|
361
|
+
parsedAst = applyAfterParsePlugins(parsedAst, plugins);
|
|
362
|
+
|
|
363
|
+
let ast = parsedAst;
|
|
364
|
+
if (astTransform) {
|
|
365
|
+
try {
|
|
366
|
+
const nextAst = astTransform(parsedAst);
|
|
367
|
+
if (isMarkdownNode(nextAst)) {
|
|
368
|
+
ast = nextAst;
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
warnInDev(
|
|
372
|
+
"[react-native-nitro-markdown] astTransform threw; falling back to parsed AST.",
|
|
373
|
+
error,
|
|
374
|
+
);
|
|
375
|
+
ast = parsedAst;
|
|
376
|
+
}
|
|
131
377
|
}
|
|
132
378
|
|
|
133
379
|
return {
|
|
134
380
|
ast,
|
|
135
|
-
text: getFlattenedText(ast),
|
|
136
381
|
};
|
|
137
382
|
} catch {
|
|
138
383
|
return {
|
|
139
384
|
ast: null,
|
|
140
|
-
text: "",
|
|
141
385
|
};
|
|
142
386
|
}
|
|
143
|
-
}, [
|
|
387
|
+
}, [
|
|
388
|
+
children,
|
|
389
|
+
parserOptionGfm,
|
|
390
|
+
parserOptionMath,
|
|
391
|
+
plugins,
|
|
392
|
+
sourceAst,
|
|
393
|
+
astTransform,
|
|
394
|
+
]);
|
|
144
395
|
|
|
145
396
|
useEffect(() => {
|
|
146
397
|
onParsingInProgress?.();
|
|
147
|
-
}, [
|
|
398
|
+
}, [
|
|
399
|
+
children,
|
|
400
|
+
parserOptionGfm,
|
|
401
|
+
parserOptionMath,
|
|
402
|
+
plugins,
|
|
403
|
+
onParsingInProgress,
|
|
404
|
+
]);
|
|
148
405
|
|
|
149
406
|
useEffect(() => {
|
|
150
|
-
if (!parseResult.ast) return;
|
|
407
|
+
if (!parseResult.ast || !onParseComplete) return;
|
|
151
408
|
|
|
152
|
-
onParseComplete
|
|
409
|
+
onParseComplete({
|
|
153
410
|
raw: children,
|
|
154
411
|
ast: parseResult.ast,
|
|
155
|
-
text: parseResult.
|
|
412
|
+
text: getFlattenedText(parseResult.ast),
|
|
156
413
|
});
|
|
157
|
-
}, [children, onParseComplete, parseResult.ast
|
|
414
|
+
}, [children, onParseComplete, parseResult.ast]);
|
|
158
415
|
|
|
159
416
|
const theme = useMemo(() => {
|
|
160
417
|
const base =
|
|
@@ -165,6 +422,41 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
165
422
|
}, [userTheme, stylingStrategy]);
|
|
166
423
|
|
|
167
424
|
const baseStyles = getBaseStyles(theme);
|
|
425
|
+
const contextValue = useMemo(
|
|
426
|
+
() => ({
|
|
427
|
+
renderers,
|
|
428
|
+
theme,
|
|
429
|
+
styles: nodeStyles,
|
|
430
|
+
stylingStrategy,
|
|
431
|
+
onLinkPress,
|
|
432
|
+
}),
|
|
433
|
+
[renderers, theme, nodeStyles, stylingStrategy, onLinkPress],
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const topLevelBlocks =
|
|
437
|
+
parseResult.ast?.type === "document"
|
|
438
|
+
? (parseResult.ast.children ?? [])
|
|
439
|
+
: parseResult.ast
|
|
440
|
+
? [parseResult.ast]
|
|
441
|
+
: [];
|
|
442
|
+
const shouldVirtualizeBySetting =
|
|
443
|
+
virtualize === true ||
|
|
444
|
+
(virtualize === "auto" && topLevelBlocks.length >= virtualizationMinBlocks);
|
|
445
|
+
const shouldVirtualize =
|
|
446
|
+
parseResult.ast !== null && shouldVirtualizeBySetting;
|
|
447
|
+
|
|
448
|
+
const keyExtractor = useCallback((node: MarkdownNode, index: number) => {
|
|
449
|
+
const beg = typeof node.beg === "number" ? node.beg : index;
|
|
450
|
+
const end = typeof node.end === "number" ? node.end : index;
|
|
451
|
+
return `${node.type}:${beg}:${end}:${index}`;
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
454
|
+
const renderVirtualizedItem = useCallback(
|
|
455
|
+
({ item }: ListRenderItemInfo<MarkdownNode>): ReactElement => (
|
|
456
|
+
<NodeRenderer node={item} depth={0} inListItem={false} />
|
|
457
|
+
),
|
|
458
|
+
[],
|
|
459
|
+
);
|
|
168
460
|
|
|
169
461
|
if (!parseResult.ast) {
|
|
170
462
|
return (
|
|
@@ -175,17 +467,28 @@ export const Markdown: FC<MarkdownProps> = ({
|
|
|
175
467
|
}
|
|
176
468
|
|
|
177
469
|
return (
|
|
178
|
-
<MarkdownContext.Provider
|
|
179
|
-
value={{
|
|
180
|
-
renderers,
|
|
181
|
-
theme,
|
|
182
|
-
styles: nodeStyles,
|
|
183
|
-
stylingStrategy,
|
|
184
|
-
onLinkPress,
|
|
185
|
-
}}
|
|
186
|
-
>
|
|
470
|
+
<MarkdownContext.Provider value={contextValue}>
|
|
187
471
|
<View style={[baseStyles.container, style]}>
|
|
188
|
-
|
|
472
|
+
{shouldVirtualize ? (
|
|
473
|
+
<FlatList
|
|
474
|
+
data={topLevelBlocks}
|
|
475
|
+
renderItem={renderVirtualizedItem}
|
|
476
|
+
keyExtractor={keyExtractor}
|
|
477
|
+
style={baseStyles.virtualizedList}
|
|
478
|
+
initialNumToRender={virtualization?.initialNumToRender ?? 12}
|
|
479
|
+
maxToRenderPerBatch={virtualization?.maxToRenderPerBatch ?? 12}
|
|
480
|
+
windowSize={virtualization?.windowSize ?? 10}
|
|
481
|
+
updateCellsBatchingPeriod={
|
|
482
|
+
virtualization?.updateCellsBatchingPeriod ?? 16
|
|
483
|
+
}
|
|
484
|
+
removeClippedSubviews={
|
|
485
|
+
virtualization?.removeClippedSubviews ?? true
|
|
486
|
+
}
|
|
487
|
+
showsVerticalScrollIndicator={false}
|
|
488
|
+
/>
|
|
489
|
+
) : (
|
|
490
|
+
<NodeRenderer node={parseResult.ast} depth={0} inListItem={false} />
|
|
491
|
+
)}
|
|
189
492
|
</View>
|
|
190
493
|
</MarkdownContext.Provider>
|
|
191
494
|
);
|
|
@@ -206,7 +509,7 @@ const isInline = (type: MarkdownNode["type"]): boolean => {
|
|
|
206
509
|
);
|
|
207
510
|
};
|
|
208
511
|
|
|
209
|
-
const
|
|
512
|
+
const NodeRendererComponent: FC<NodeRendererProps> = ({
|
|
210
513
|
node,
|
|
211
514
|
depth,
|
|
212
515
|
inListItem,
|
|
@@ -521,6 +824,15 @@ const NodeRenderer: FC<NodeRendererProps> = ({
|
|
|
521
824
|
}
|
|
522
825
|
};
|
|
523
826
|
|
|
827
|
+
const NodeRenderer = memo(NodeRendererComponent, (previousProps, nextProps) => {
|
|
828
|
+
return (
|
|
829
|
+
previousProps.node === nextProps.node &&
|
|
830
|
+
previousProps.depth === nextProps.depth &&
|
|
831
|
+
previousProps.inListItem === nextProps.inListItem &&
|
|
832
|
+
previousProps.parentIsText === nextProps.parentIsText
|
|
833
|
+
);
|
|
834
|
+
}) as FC<NodeRendererProps>;
|
|
835
|
+
|
|
524
836
|
type BaseStyles = ReturnType<typeof createBaseStyles>;
|
|
525
837
|
|
|
526
838
|
const getBaseStyles = (theme: MarkdownTheme): BaseStyles => {
|
|
@@ -537,6 +849,9 @@ const createBaseStyles = (theme: MarkdownTheme) =>
|
|
|
537
849
|
container: {
|
|
538
850
|
flex: 1,
|
|
539
851
|
},
|
|
852
|
+
virtualizedList: {
|
|
853
|
+
flex: 1,
|
|
854
|
+
},
|
|
540
855
|
document: {
|
|
541
856
|
flex: 1,
|
|
542
857
|
},
|
package/src/renderers/code.tsx
CHANGED
|
@@ -70,7 +70,11 @@ export const CodeBlock: FC<CodeBlockProps> = ({
|
|
|
70
70
|
{showLanguage ? (
|
|
71
71
|
<Text style={styles.codeLanguage}>{language}</Text>
|
|
72
72
|
) : null}
|
|
73
|
-
<ScrollView
|
|
73
|
+
<ScrollView
|
|
74
|
+
horizontal
|
|
75
|
+
showsHorizontalScrollIndicator={false}
|
|
76
|
+
bounces={false}
|
|
77
|
+
>
|
|
74
78
|
<Text style={styles.codeBlockText}>{displayContent}</Text>
|
|
75
79
|
</ScrollView>
|
|
76
80
|
</View>
|