Skip to content
Source
Sign inSign up

EnrichedMarkdownText.tsx

475 lines16.3 KB

1import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
2import {
3 collectComposerContextReferences,
4 formatComposerContextHref,
5 formatComposerContextReference,
6} from "@t3tools/shared/composerContextReferences";
7import {
8 markdownForNativeRendering,
9 markdownWithFileEmbeds,
10 markdownForNativeParsing,
11 markdownWithInlineFileLinks,
12 markdownWithTableBreaks,
13} from "../lib/markdownDocument";
14import { isValidElement, useMemo, useState, type ReactElement, type ReactNode } from "react";
15import { Platform, Share, StyleSheet, useWindowDimensions, View } from "react-native";
16import {
17 EnrichedMarkdownText,
18 type MarkdownCodeBlock,
19 type MarkdownImageBlock,
20 type MarkdownLinkOccurrence,
21 type MarkdownStyle,
22} from "react-native-enriched-markdown";
23import { codeFenceRenderer } from "./codeBlockMarkdown";
24import { HighlightedCodeBlock } from "./HighlightedCodeBlock";
25import { MermaidCodeBlock } from "./MermaidCodeBlock";
26import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";
27​
28import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
29import { Menu } from "../components/ui/Menu";
30import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants";
31import { useRetryingImageSource } from "./useRetryingImageSource";
32import * as Dialog from "../components/ui/Dialog";
33import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
34import type { HighlightScheme } from "../lib/highlights";
35import {
36 ENRICHED_INLINE_FILE_LINK_REGEX,
37 enrichedTextLinkRegex,
38 enrichedModelDetails,
39 enrichedModelReference,
40 enrichedLinkVariantPattern,
41} from "../lib/enrichedLinkPresentation";
42​
43function enrichedStyle(
44 style: SelectableMarkdownTextProps["textStyle"],
45 dark: boolean,
46 fontScale: number,
47): MarkdownStyle {
48 const monospace = Platform.OS === "ios" ? "Menlo" : "monospace";
49 // Keep the radius modest: iOS clips square table-cell borders without stroking the corner arcs.
50 const block = {
51 marginTop: 4,
52 marginBottom: 12,
53 borderRadius: 8,
54 borderWidth: StyleSheet.hairlineWidth,
55 borderColor: style.dividerColor,
56 };
57 const body = {
58 color: style.color,
59 fontFamily: style.fontFamily,
60 fontSize: style.fontSize,
61 lineHeight: style.lineHeight,
62 marginTop: 0,
63 marginBottom: 8,
64 };
65 const heading = (level: number) => {
66 const fontSize = style.headingFontSizes?.[level - 1] ?? style.fontSize * (1.6 - level * 0.1);
67 return {
68 ...body,
69 color: style.strongColor,
70 fontFamily: style.headingFontFamily,
71 fontSize,
72 lineHeight: fontSize * 1.35,
73 marginTop: 8,
74 };
75 };
76 return {
77 paragraph: body,
78 math: {
79 fontSize: style.fontSize,
80 color: style.color,
81 backgroundColor: "transparent",
82 padding: 8,
83 marginTop: 4,
84 marginBottom: 12,
85 },
86 inlineMath: { color: style.color },
87 h1: heading(1),
88 h2: heading(2),
89 h3: heading(3),
90 h4: heading(4),
91 h5: heading(5),
92 h6: heading(6),
93 strong: { color: style.strongColor, fontFamily: style.boldFontFamily, fontWeight: "normal" },
94 link: { color: style.linkColor, underline: false },
95 code: {
96 fontFamily: monospace,
97 color: style.inlineCodeColor,
98 backgroundColor: "transparent",
99 borderColor: "transparent",
100 fontSize: style.fontSize * 0.9,
101 },
102 codeBlock: {
103 ...body,
104 color: style.codeColor,
105 backgroundColor: style.codeBlockBackgroundColor,
106 fontFamily: monospace,
107 fontSize: style.fontSize * 0.9,
108 ...block,
109 padding: 12,
110 syntaxColors: dark
111 ? {
112 keyword: "#ff7b72",
113 string: "#a5d6ff",
114 number: "#79c0ff",
115 constant: "#79c0ff",
116 comment: "#8b949e",
117 function: "#d2a8ff",
118 type: "#ffa657",
119 property: "#79c0ff",
120 tag: "#7ee787",
121 attribute: "#79c0ff",
122 }
123 : {
124 keyword: "#cf222e",
125 string: "#0a3069",
126 number: "#0550ae",
127 constant: "#0550ae",
128 comment: "#6e7781",
129 function: "#8250df",
130 type: "#953800",
131 property: "#0550ae",
132 tag: "#116329",
133 attribute: "#0550ae",
134 },
135 },
136 blockquote: {
137 ...body,
138 color: style.mutedColor,
139 backgroundColor: "transparent",
140 borderColor: style.quoteMarkerColor,
141 borderWidth: 2,
142 gapWidth: 12,
143 },
144 list: {
145 ...body,
146 bulletColor: style.mutedColor,
147 markerColor: style.mutedColor,
148 // The bullet is centred on the gap's edge and scales with the system text size; the gap must too.
149 gapWidth: 12 * fontScale,
150 },
151 thematicBreak: { color: style.dividerColor, height: 1, marginTop: 8, marginBottom: 8 },
152 table: {
153 ...body,
154 ...block,
155 headerFontFamily: style.boldFontFamily,
156 headerTextColor: style.strongColor,
157 headerBackgroundColor: style.codeBlockBackgroundColor,
158 rowEvenBackgroundColor: "transparent",
159 rowOddBackgroundColor: "transparent",
160 cellPaddingHorizontal: 10,
161 cellPaddingVertical: 7,
162 },
163 taskList: {
164 checkedColor: style.linkColor,
165 checkedTextColor: style.color,
166 borderColor: style.mutedColor,
167 checkedStrikethrough: false,
168 },
169 };
171​
172/**
173 * Diagram and math fences, as web draws them, and code enriched-markdown cannot colour.
174 * Diagrams and math stay native until the library reports them complete; fallback code keeps one block throughout.
175 */
176function codeBlockElement(
177 block: MarkdownCodeBlock,
178 textStyle: SelectableMarkdownTextProps["textStyle"],
179 markdownStyle: MarkdownStyle,
180 mathRenderingEnabled: boolean,
181 scheme: HighlightScheme,
182): ReactElement | undefined {
183 const renderer = codeFenceRenderer(block.language, mathRenderingEnabled);
184 if (renderer.kind === "mermaid") {
185 return block.isComplete ? (
186 <MermaidCodeBlock code={block.code} textStyle={textStyle} />
187 ) : undefined;
188 }
189 if (renderer.kind === "math") {
190 return block.isComplete ? (
191 <EnrichedMarkdownText
192 // A blank line would end the paragraph, and so the math, early.
193 markdown={`$$\n${block.code.replace(/\n\s*\n/g, "\n").trim()}\n$$`}
194 markdownStyle={markdownStyle}
195 md4cFlags={{ latexMath: true }}
196 containerStyle={{ flexShrink: 1, minWidth: 0 }}
197 flavor="github"
198 selectable
199 selectionColor={textStyle.selectionColor}
200 selectionHandleColor={textStyle.selectionHandleColor}
201 />
202 ) : undefined;
203 }
204 if (renderer.kind === "highlight" && markdownStyle.codeBlock) {
205 return (
206 <HighlightedCodeBlock
207 code={block.code}
208 fence={block.language}
209 language={renderer.language}
210 complete={block.isComplete}
211 scheme={scheme}
212 textStyle={textStyle}
213 codeStyle={markdownStyle.codeBlock}
214 />
215 );
216 }
217 return undefined;
219​
220export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
221 const { renderImage, resolveImageSource, fileEmbedLabel } = props;
222 const { themeAppearance, mathRenderingEnabled } = useAppearancePreferences();
223 const { markdown, fileHrefs } = useMemo(() => {
224 if (!fileEmbedLabel) {
225 return {
226 markdown: markdownForNativeRendering(props.markdown, ENRICHED_INLINE_FILE_LINK_REGEX),
227 fileHrefs: new Set<string>(),
228 };
229 }
230 const content = markdownWithInlineFileLinks(
231 markdownForNativeParsing(props.markdown),
232 ENRICHED_INLINE_FILE_LINK_REGEX,
233 );
234 const files = markdownWithFileEmbeds(content, fileEmbedLabel);
235 return { ...files, markdown: markdownWithTableBreaks(files.markdown) };
236 }, [props.markdown, fileEmbedLabel]);
237 const [parsedLinkUrls, setParsedLinkUrls] = useState<ReadonlyArray<string>>([]);
238 const onLinksChange = (links: MarkdownLinkOccurrence[]) => {
239 const urls = [...new Set(links.map((link) => link.url))];
240 setParsedLinkUrls((previous) =>
241 previous.length === urls.length && previous.every((url, index) => url === urls[index])
242 ? previous
243 : urls,
244 );
245 };
246 const textLinkRegex = useMemo(
247 () => enrichedTextLinkRegex(props.skills, props.models),
248 [props.skills, props.models],
249 );
250 // Recognized text links are added as the text is drawn, so
251 // onLinksChange never reports them. Joined so a streaming tick keeps the value.
252 const textLinkKey = useMemo(
253 () =>
254 textLinkRegex
255 ? [...new Set(markdown.match(new RegExp(textLinkRegex.source, "gu")) ?? [])].join("\n")
256 : "",
257 [markdown, textLinkRegex],
258 );
259 const linkUrls = textLinkKey
260 ? [...new Set([...parsedLinkUrls, ...textLinkKey.split("\n")])]
261 : parsedLinkUrls;
262 const { linkVariants, linkPillContent } = useEnrichedLinkVariants(linkUrls, props, fileHrefs);
263 const linkRecognition = useMemo(() => ({ text: textLinkRegex ?? undefined }), [textLinkRegex]);
264 const [activeMenu, setActiveMenu] = useState<{
265 url: string;
266 menu: NonNullable<ReturnType<NonNullable<typeof props.fileContextMenu>>>;
267 } | null>(null);
268 const { fileContextMenu, onFileContextMenuAction } = props;
269 const onLinkPress = (url: string) => {
270 const model = enrichedModelReference(url, props.models);
271 if (model) {
272 Dialog.alert(model.label, enrichedModelDetails(model));
273 return;
274 }
275 (fileHrefs.has(url) ? (props.onImagePress ?? props.onLinkPress) : props.onLinkPress)?.(url);
276 };
277 const modelContextMenu = (url: string) => {
278 const model = enrichedModelReference(url, props.models);
279 return model
280 ? {
281 title: model.label,
282 actions: [
283 { id: "enriched-copy-link", title: "Copy model reference" },
284 { id: "enriched-share-link", title: "Share model reference" },
285 ],
286 }
287 : undefined;
288 };
289 const performMenuAction = (url: string, actionId: string) => {
290 if (actionId === "enriched-open-link") onLinkPress(url);
291 else if (actionId === "enriched-copy-link") copyTextWithHaptic(url);
292 else if (actionId === "enriched-share-link") void Share.share({ message: url });
293 else onFileContextMenuAction?.(url, actionId);
294 };
295 const linkContextMenuItems =
296 Platform.OS === "ios" && (fileContextMenu || props.onLinkPress || props.onImagePress)
297 ? Object.fromEntries(
298 linkUrls.map((url) => {
299 const menu = modelContextMenu(url) ??
300 fileContextMenu?.(url) ?? {
301 title: url,
302 actions: [
303 { id: "enriched-open-link", title: "Open link" },
304 { id: "enriched-copy-link", title: "Copy link" },
305 { id: "enriched-share-link", title: "Share link" },
306 ],
307 };
308 return [
309 enrichedLinkVariantPattern(url),
310 menu.actions.map((action) => ({
311 text: action.title,
312 disabled: "disabled" in action && action.disabled,
313 onPress: () => performMenuAction(url, action.id),
314 })),
315 ];
316 }),
317 )
318 : undefined;
319 const selectionClipboard = useMemo(() => {
320 const fragment = decodeComposerContextFragment(props.contextClipboardFragment);
321 if (!fragment) return undefined;
322 const references = collectComposerContextReferences(props.markdown);
323 const linkTextByUrl = Object.fromEntries(
324 references.flatMap((reference) => {
325 const record = fragment.records.find((entry) => entry.contextId === reference.contextId);
326 return record
327 ? [
328 [
329 formatComposerContextHref(reference.kind, reference.contextId),
330 formatComposerContextReference({ ...reference, label: record.label }),
331 ],
332 ]
333 : [];
334 }),
335 );
336 return {
337 linkTextByUrl,
338 htmlAttributes: {
339 "data-t3-context-fragment": encodeURIComponent(props.contextClipboardFragment!),
340 },
341 mimeTypes: { "app.t3.context-fragment": props.contextClipboardFragment! },
342 };
343 }, [props.contextClipboardFragment, props.markdown]);
344 const { fontScale } = useWindowDimensions();
345 const baseMarkdownStyle = useMemo(() => {
346 const style = enrichedStyle(props.textStyle, themeAppearance === "dark", fontScale);
347 return {
348 ...style,
349 paragraph: props.textAlign
350 ? { ...style.paragraph, textAlign: props.textAlign }
351 : style.paragraph,
352 };
353 }, [props.textStyle, props.textAlign, themeAppearance, fontScale]);
354 // Link variants change as a message streams. The code renderer reads only the base style, so
355 // it stays the same function and the library keeps its rendered blocks.
356 const markdownStyle = useMemo(
357 () => ({ ...baseMarkdownStyle, linkVariants }),
358 [baseMarkdownStyle, linkVariants],
359 );
360 const renderers = {
361 image: renderImage
362 ? (block: MarkdownImageBlock) => {
363 const element = renderImage({
364 href: block.url,
365 alt: block.altText || null,
366 title: block.title || null,
367 });
368 return isValidElement(element) ? element : undefined;
369 }
370 : undefined,
371 code: (block: MarkdownCodeBlock) =>
372 codeBlockElement(
373 block,
374 props.textStyle,
375 baseMarkdownStyle,
376 mathRenderingEnabled,
377 themeAppearance,
378 ),
379 };
380 const resolveNativeImageSource = useRetryingImageSource(resolveImageSource);
381 const renderMarkdown = (openAndroidMenu?: () => void) => {
382 const onLinkLongPress =
383 Platform.OS === "android" && props.fileContextMenu
384 ? ({ url }: { url: string }) => {
385 const menu = modelContextMenu(url) ??
386 props.fileContextMenu?.(url) ?? {
387 title: url,
388 actions: [
389 { id: "enriched-open-link", title: "Open link" },
390 { id: "enriched-copy-link", title: "Copy link" },
391 ],
392 };
393 setActiveMenu({ url, menu });
394 openAndroidMenu?.();
395 }
396 : undefined;
397​
398 return (
399 <View
400 style={{
401 flexShrink: 1,
402 minWidth: 0,
403 marginTop: props.marginTop,
404 marginBottom: props.marginBottom,
405 }}
406 >
407 <EnrichedMarkdownText
408 markdown={markdown}
409 markdownStyle={markdownStyle}
410 writingDirection={props.writingDirection}
411 onLinksChange={onLinksChange}
412 renderers={renderers}
413 resolveImageSource={resolveNativeImageSource}
414 onImagePress={
415 props.onImagePress || props.onLinkPress
416 ? ({ url }) => (props.onImagePress ?? props.onLinkPress)?.(url)
417 : undefined
418 }
419 linkRecognition={linkRecognition}
420 containerStyle={{ flexShrink: 1, minWidth: 0 }}
421 flavor="github"
422 selectable
423 selectionClipboard={selectionClipboard}
424 selectionColor={props.textStyle.selectionColor}
425 selectionHandleColor={props.textStyle.selectionHandleColor}
426 md4cFlags={{
427 latexMath: mathRenderingEnabled,
428 hardSoftBreaks: props.preserveSoftBreaks ?? false,
429 admonitions: true,
430 preserveWindowsPaths: true,
431 }}
432 enableTaskListItemToggle={props.onTaskListItemPress !== undefined}
433 onTaskListItemPress={props.onTaskListItemPress}
434 enableCodeBlockWrapToggle
435 spoilerOverlay="solid"
436 onLinkPress={
437 props.onLinkPress || props.onImagePress ? ({ url }) => onLinkPress(url) : undefined
438 }
439 onLinkLongPress={onLinkLongPress}
440 linkContextMenuItems={linkContextMenuItems}
441 linkPillContent={linkPillContent}
442 />
443 </View>
444 );
445 };
446​
447 return Platform.OS === "android" && props.fileContextMenu ? (
448 <Menu
449 shouldOpenOnLongPress
450 title={activeMenu?.menu.title}
451 actions={
452 activeMenu?.menu.actions.map((action) => ({
453 id: action.id,
454 title: action.title,
455 attributes: { disabled: action.disabled },
456 })) ?? []
457 }
458 onPressAction={({ nativeEvent }) => {
459 if (activeMenu) performMenuAction(activeMenu.url, nativeEvent.event);
460 }}
461 style={{ flexShrink: 1, minWidth: 0 }}
462 >
463 <MarkdownMenuAnchor renderMarkdown={renderMarkdown} />
464 </Menu>
465 ) : (
466 renderMarkdown()
467 );
469​
470function MarkdownMenuAnchor(props: {
471 readonly renderMarkdown: (openMenu?: () => void) => ReactNode;
472 readonly onLongPress?: () => void;
473}) {
474 return props.renderMarkdown(props.onLongPress);