authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-10 18:48:56-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 15:48:56-07:00
log1a9336bcda98e75814c1985e9274d919e580dbc5
tree9bffb3288eded92d0632b9f40b93a627284ecc19
parent859304b7808ab9a4be87b1bddcd07c6485bf9c4f
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): name shared markdown renderer without iOS suffixes (#11128)


7 files changed, 1175 insertions(+), 1190 deletions(-)

apps/mobile/modules/t3-markdown-text/package.json+1-1
......@@ -25,7 +25,7 @@
2525 "./links": "./src/markdownLinks.ts",
2626 "./markdown": "./src/nativeMarkdownText.ts",
2727 "./primitive": "./src/MarkdownTextPrimitive.tsx",
28 "./renderer": "./src/SelectableMarkdownText.ios.tsx",
28 "./renderer": "./src/SelectableMarkdownText.tsx",
2929 "./types": "./src/SelectableMarkdownText.types.ts"
3030 },
3131 "peerDependencies": {
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx deleted-755
......@@ -1,755 +0,0 @@
1import { createContext, useContext, useEffect, useState } from "react";
2import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native";
3import type { MarkdownNode } from "react-native-nitro-markdown/headless";
4
5import { CopyTextButton } from "./CopyTextButton";
6import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
7import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
8import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
9import type {
10 MarkdownCodeHighlighter,
11 MarkdownHighlightedToken,
12 MarkdownImageRenderer,
13 NativeMarkdownTextStyle,
14 SelectableMarkdownSkill,
15} from "./SelectableMarkdownText.types";
16
17/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */
18export const MarkdownImageRendererContext = createContext<MarkdownImageRenderer | null>(null);
19
20type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
21
22const highlightedCodeCache = new Map<string, HighlightedCode>();
23const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();
24const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;
25const MONO_FONT_FAMILY = Platform.select({
26 ios: "ui-monospace",
27 android: "monospace",
28 default: "monospace",
29});
30
31function nodeKey(node: MarkdownNode, index: number): string {
32 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
33}
34
35/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */
36function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number {
37 return Math.max(10, Math.round(textStyle.fontSize * 0.8));
38}
39
40function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number {
41 return codeBlockFontSize(textStyle) + 6;
42}
43
44function nodeText(node: MarkdownNode): string {
45 if (node.content !== undefined) {
46 return node.content;
47 }
48 return (node.children ?? []).map(nodeText).join("");
49}
50
51function documentFor(node: MarkdownNode): MarkdownNode {
52 return node.type === "document" ? node : { type: "document", children: [node] };
53}
54
55function SelectableNode(props: {
56 readonly node: MarkdownNode;
57 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
58 readonly textStyle: NativeMarkdownTextStyle;
59 readonly onLinkPress?: (href: string) => void;
60}) {
61 return (
62 <NativeMarkdownSelectableText
63 runs={nativeMarkdownDocumentRuns(documentFor(props.node), props.skills)}
64 textStyle={props.textStyle}
65 onLinkPress={props.onLinkPress}
66 />
67 );
68}
69
70function codeHighlightCacheKey(
71 code: string,
72 language: string | undefined,
73 theme: "light" | "dark",
74): string {
75 return `${theme}:${language ?? "text"}:${code}`;
76}
77
78function cacheHighlightedCode(key: string, tokens: HighlightedCode): void {
79 highlightedCodeCache.delete(key);
80 highlightedCodeCache.set(key, tokens);
81
82 while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) {
83 const oldestKey = highlightedCodeCache.keys().next().value;
84 if (oldestKey === undefined) {
85 break;
86 }
87 highlightedCodeCache.delete(oldestKey);
88 }
89}
90
91function loadHighlightedCode(
92 code: string,
93 language: string | undefined,
94 theme: "light" | "dark",
95 highlightCode: MarkdownCodeHighlighter,
96): Promise<HighlightedCode> {
97 const key = codeHighlightCacheKey(code, language, theme);
98 const cached = highlightedCodeCache.get(key);
99 if (cached) {
100 return Promise.resolve(cached);
101 }
102
103 const pending = highlightedCodePromiseCache.get(key);
104 if (pending) {
105 return pending;
106 }
107
108 const promise = highlightCode({ code, language, theme })
109 .then((tokens) => {
110 cacheHighlightedCode(key, tokens);
111 highlightedCodePromiseCache.delete(key);
112 return tokens;
113 })
114 .catch((error) => {
115 highlightedCodePromiseCache.delete(key);
116 throw error;
117 });
118 highlightedCodePromiseCache.set(key, promise);
119 return promise;
120}
121
122function useHighlightedCode(
123 code: string,
124 language: string | undefined,
125 theme: "light" | "dark",
126 highlightCode: MarkdownCodeHighlighter,
127): HighlightedCode | null {
128 const key = codeHighlightCacheKey(code, language, theme);
129 const [highlighted, setHighlighted] = useState<{
130 readonly key: string;
131 readonly tokens: HighlightedCode | null;
132 }>(() => ({
133 key,
134 tokens: highlightedCodeCache.get(key) ?? null,
135 }));
136
137 useEffect(() => {
138 let active = true;
139 const cached = highlightedCodeCache.get(key);
140 if (cached) {
141 cacheHighlightedCode(key, cached);
142 setHighlighted({ key, tokens: cached });
143 return () => {
144 active = false;
145 };
146 }
147
148 void loadHighlightedCode(code, language, theme, highlightCode)
149 .then((tokens) => {
150 if (active) {
151 setHighlighted({ key, tokens });
152 }
153 })
154 .catch(() => {
155 if (active) {
156 setHighlighted({ key, tokens: null });
157 }
158 });
159 return () => {
160 active = false;
161 };
162 }, [code, highlightCode, key, language, theme]);
163
164 return highlighted.key === key ? highlighted.tokens : null;
165}
166
167function HighlightedCodeText(props: {
168 readonly content: string;
169 readonly highlighted: HighlightedCode | null;
170 readonly textStyle: NativeMarkdownTextStyle;
171}) {
172 if (!props.highlighted) {
173 return (
174 <MarkdownTextPrimitive
175 uiTextView
176 selectable
177 style={{
178 color: props.textStyle.codeColor,
179 fontFamily: MONO_FONT_FAMILY,
180 fontSize: codeBlockFontSize(props.textStyle),
181 lineHeight: codeBlockLineHeight(props.textStyle),
182 }}
183 >
184 {props.content}
185 </MarkdownTextPrimitive>
186 );
187 }
188 const highlighted = props.highlighted;
189 let sourceOffset = 0;
190 const keyOccurrences = new Map<string, number>();
191 const keyedLines = highlighted.map((line) => {
192 const lineStart = sourceOffset;
193 const tokens = line.map((token) => {
194 const start = sourceOffset;
195 sourceOffset += token.content.length;
196 const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`;
197 const occurrence = keyOccurrences.get(signature) ?? 0;
198 keyOccurrences.set(signature, occurrence + 1);
199 return { key: `${signature}:${occurrence}`, token };
200 });
201 sourceOffset += 1;
202 return {
203 key: `line:${lineStart}:${line.map((token) => token.content).join("")}`,
204 tokens,
205 };
206 });
207
208 return (
209 <MarkdownTextPrimitive
210 uiTextView
211 selectable
212 style={{
213 color: props.textStyle.codeColor,
214 fontFamily: MONO_FONT_FAMILY,
215 fontSize: codeBlockFontSize(props.textStyle),
216 lineHeight: codeBlockLineHeight(props.textStyle),
217 }}
218 >
219 {keyedLines.map((line, lineIndex) => (
220 <MarkdownTextPrimitive key={line.key}>
221 {line.tokens.map(({ key, token }) => (
222 <MarkdownTextPrimitive
223 key={key}
224 style={{
225 color: token.color ?? props.textStyle.codeColor,
226 fontFamily: MONO_FONT_FAMILY,
227 fontStyle:
228 token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal",
229 fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",
230 }}
231 >
232 {token.content}
233 </MarkdownTextPrimitive>
234 ))}
235 {lineIndex + 1 < keyedLines.length ? "\n" : ""}
236 </MarkdownTextPrimitive>
237 ))}
238 </MarkdownTextPrimitive>
239 );
240}
241
242function NativeCodeBlock(props: {
243 readonly node: MarkdownNode;
244 readonly textStyle: NativeMarkdownTextStyle;
245 readonly highlightCode: MarkdownCodeHighlighter;
246 readonly compact?: boolean;
247}) {
248 const content = nodeText(props.node).replace(/\n$/, "");
249 const colorScheme = useColorScheme();
250 const theme = colorScheme === "dark" ? "dark" : "light";
251 const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode);
252 const languageLabel = props.node.language?.toUpperCase() ?? "CODE";
253 return (
254 <View
255 style={{
256 backgroundColor: props.textStyle.codeBlockBackgroundColor,
257 borderColor: props.textStyle.dividerColor,
258 borderCurve: "continuous",
259 borderRadius: 10,
260 borderWidth: 1,
261 marginVertical: props.compact ? 7 : 0,
262 overflow: "hidden",
263 }}
264 >
265 <View
266 style={{
267 minHeight: 42,
268 borderBottomColor: props.textStyle.dividerColor,
269 borderBottomWidth: 1,
270 paddingLeft: 14,
271 paddingRight: 6,
272 flexDirection: "row",
273 alignItems: "center",
274 justifyContent: "space-between",
275 }}
276 >
277 <Text
278 selectable
279 style={{
280 flex: 1,
281 color: props.textStyle.mutedColor,
282 fontFamily: MONO_FONT_FAMILY,
283 fontSize: codeBlockFontSize(props.textStyle),
284 }}
285 >
286 {languageLabel}
287 </Text>
288 <CopyTextButton
289 accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}
290 text={content}
291 tintColor={props.textStyle.mutedColor}
292 copiedTintColor={props.textStyle.linkColor}
293 backgroundColor={props.textStyle.codeBackgroundColor}
294 borderColor={props.textStyle.dividerColor}
295 buttonSize={34}
296 iconSize={14}
297 />
298 </View>
299 <ScrollView
300 horizontal
301 bounces={false}
302 nestedScrollEnabled={Platform.OS === "android"}
303 showsHorizontalScrollIndicator={false}
304 contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}
305 >
306 <HighlightedCodeText
307 content={content}
308 highlighted={highlighted}
309 textStyle={props.textStyle}
310 />
311 </ScrollView>
312 </View>
313 );
314}
315
316function collectTableRows(node: MarkdownNode): MarkdownNode[] {
317 const rows: MarkdownNode[] = [];
318 const visit = (child: MarkdownNode) => {
319 if (child.type === "table_row") {
320 rows.push(child);
321 return;
322 }
323 for (const nested of child.children ?? []) {
324 visit(nested);
325 }
326 };
327 visit(node);
328 return rows;
329}
330
331function NativeTable(props: {
332 readonly node: MarkdownNode;
333 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
334 readonly textStyle: NativeMarkdownTextStyle;
335 readonly onLinkPress?: (href: string) => void;
336}) {
337 const rows = collectTableRows(props.node);
338 return (
339 <ScrollView
340 horizontal
341 bounces={false}
342 nestedScrollEnabled={Platform.OS === "android"}
343 showsHorizontalScrollIndicator={false}
344 >
345 <View
346 style={{
347 borderColor: props.textStyle.dividerColor,
348 borderCurve: "continuous",
349 borderRadius: 8,
350 borderWidth: 1,
351 overflow: "hidden",
352 }}
353 >
354 {rows.map((row, rowIndex) => (
355 <View
356 key={nodeKey(row, rowIndex)}
357 style={{
358 flexDirection: "row",
359 backgroundColor: rowIndex === 0 ? props.textStyle.codeBackgroundColor : "transparent",
360 borderTopColor: props.textStyle.dividerColor,
361 borderTopWidth: rowIndex === 0 ? 0 : 1,
362 }}
363 >
364 {(row.children ?? []).map((cell, cellIndex) => (
365 <View
366 key={nodeKey(cell, cellIndex)}
367 style={{
368 width: 160,
369 borderLeftColor: props.textStyle.dividerColor,
370 borderLeftWidth: cellIndex === 0 ? 0 : 1,
371 paddingHorizontal: 10,
372 paddingVertical: 8,
373 }}
374 >
375 <NativeMarkdownSelectableText
376 runs={nativeMarkdownDocumentRuns(documentFor(cell), props.skills).map((run) =>
377 rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run,
378 )}
379 textStyle={props.textStyle}
380 onLinkPress={props.onLinkPress}
381 />
382 </View>
383 ))}
384 </View>
385 ))}
386 </View>
387 </ScrollView>
388 );
389}
390
391function NativeMarkdownImage(props: {
392 readonly node: MarkdownNode;
393 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
394 readonly textStyle: NativeMarkdownTextStyle;
395 readonly onLinkPress?: (href: string) => void;
396}) {
397 const renderImage = useContext(MarkdownImageRendererContext);
398 const href = props.node.href;
399 if (!href) {
400 return (
401 <SelectableNode
402 node={props.node}
403 skills={props.skills}
404 textStyle={props.textStyle}
405 onLinkPress={props.onLinkPress}
406 />
407 );
408 }
409
410 if (renderImage) {
411 const rendered = renderImage({
412 href,
413 alt: props.node.alt ?? null,
414 title: props.node.title ?? null,
415 });
416 if (rendered != null) {
417 return <>{rendered}</>;
418 }
419 }
420
421 return (
422 <View style={{ gap: 6 }}>
423 <Image
424 source={{ uri: href }}
425 resizeMode="contain"
426 accessibilityLabel={props.node.alt ?? props.node.title}
427 style={{
428 width: "100%",
429 aspectRatio: 16 / 9,
430 backgroundColor: props.textStyle.codeBackgroundColor,
431 borderRadius: 10,
432 }}
433 />
434 {props.node.alt ? (
435 <Text
436 selectable
437 style={{
438 color: props.textStyle.mutedColor,
439 fontFamily: props.textStyle.fontFamily,
440 fontSize: 12,
441 lineHeight: 16,
442 }}
443 >
444 {props.node.alt}
445 </Text>
446 ) : null}
447 </View>
448 );
449}
450
451function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] {
452 const groups: MarkdownNode[] = [];
453 let inline: MarkdownNode[] = [];
454 const flush = () => {
455 if (inline.length === 0) {
456 return;
457 }
458 groups.push({ type: "paragraph", children: inline });
459 inline = [];
460 };
461
462 for (const node of nodes) {
463 if (node.type === "image") {
464 flush();
465 groups.push(node);
466 } else {
467 inline.push(node);
468 }
469 }
470 flush();
471 return groups;
472}
473
474function NativeMixedParagraph(props: {
475 readonly node: MarkdownNode;
476 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
477 readonly textStyle: NativeMarkdownTextStyle;
478 readonly onLinkPress?: (href: string) => void;
479}) {
480 return (
481 <View style={{ gap: 8 }}>
482 {inlineGroups(props.node.children ?? []).map((child, index) =>
483 child.type === "image" ? (
484 <NativeMarkdownImage
485 key={nodeKey(child, index)}
486 node={child}
487 skills={props.skills}
488 textStyle={props.textStyle}
489 onLinkPress={props.onLinkPress}
490 />
491 ) : (
492 <SelectableNode
493 key={nodeKey(child, index)}
494 node={child}
495 skills={props.skills}
496 textStyle={props.textStyle}
497 onLinkPress={props.onLinkPress}
498 />
499 ),
500 )}
501 </View>
502 );
503}
504
505function NativeList(props: {
506 readonly node: MarkdownNode;
507 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
508 readonly textStyle: NativeMarkdownTextStyle;
509 readonly highlightCode: MarkdownCodeHighlighter;
510 readonly onLinkPress?: (href: string) => void;
511 readonly depth: number;
512}) {
513 const ordered = props.node.ordered ?? false;
514 const start = props.node.start ?? 1;
515 const nested = props.depth > 0;
516 return (
517 <View
518 style={{
519 gap: nested ? 3 : 5,
520 }}
521 >
522 {(props.node.children ?? []).map((item, index) => {
523 const taskMarker = item.type === "task_list_item";
524 const marker = taskMarker
525 ? item.checked
526 ? "☑︎"
527 : "☐︎"
528 : ordered
529 ? `${start + index}.`
530 : props.depth % 3 === 1
531 ? "◦"
532 : props.depth % 3 === 2
533 ? "▪︎"
534 : "•";
535 const markerWidth = ordered ? 28 : taskMarker ? 20 : 18;
536 const markerOffset = taskMarker ? 3 : ordered ? 0 : 2;
537 return (
538 <View
539 key={nodeKey(item, index)}
540 style={{ alignItems: "flex-start", flexDirection: "row" }}
541 >
542 <View
543 style={{
544 width: markerWidth,
545 height: props.textStyle.lineHeight,
546 marginRight: 6,
547 alignItems: ordered ? "flex-end" : "center",
548 justifyContent: "flex-start",
549 }}
550 >
551 <Text
552 style={{
553 color: props.textStyle.color,
554 fontFamily: props.textStyle.fontFamily,
555 fontSize: taskMarker ? 14 : props.textStyle.fontSize,
556 lineHeight: props.textStyle.lineHeight,
557 fontVariant: ordered ? ["tabular-nums"] : undefined,
558 transform: [{ translateY: markerOffset }],
559 }}
560 >
561 {marker}
562 </Text>
563 </View>
564 <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
565 {nativeMarkdownListItemBlocks(item).map((child, childIndex) => (
566 <NativeMarkdownBlock
567 key={nodeKey(child, childIndex)}
568 node={child}
569 skills={props.skills}
570 textStyle={props.textStyle}
571 highlightCode={props.highlightCode}
572 onLinkPress={props.onLinkPress}
573 depth={props.depth + 1}
574 compact
575 />
576 ))}
577 </View>
578 </View>
579 );
580 })}
581 </View>
582 );
583}
584
585export function NativeMarkdownBlock(props: {
586 readonly node: MarkdownNode;
587 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
588 readonly textStyle: NativeMarkdownTextStyle;
589 readonly highlightCode: MarkdownCodeHighlighter;
590 readonly onLinkPress?: (href: string) => void;
591 readonly depth?: number;
592 readonly compact?: boolean;
593}) {
594 const depth = props.depth ?? 0;
595 switch (props.node.type) {
596 case "document":
597 return (
598 <View style={{ gap: 8 }}>
599 {(props.node.children ?? []).map((child, index) => (
600 <NativeMarkdownBlock
601 key={nodeKey(child, index)}
602 node={child}
603 skills={props.skills}
604 textStyle={props.textStyle}
605 highlightCode={props.highlightCode}
606 onLinkPress={props.onLinkPress}
607 depth={depth}
608 />
609 ))}
610 </View>
611 );
612 case "code_block":
613 return (
614 <NativeCodeBlock
615 node={props.node}
616 textStyle={props.textStyle}
617 highlightCode={props.highlightCode}
618 compact={props.compact}
619 />
620 );
621 case "table":
622 return (
623 <NativeTable
624 node={props.node}
625 skills={props.skills}
626 textStyle={props.textStyle}
627 onLinkPress={props.onLinkPress}
628 />
629 );
630 case "image":
631 return (
632 <NativeMarkdownImage
633 node={props.node}
634 skills={props.skills}
635 textStyle={props.textStyle}
636 onLinkPress={props.onLinkPress}
637 />
638 );
639 case "horizontal_rule":
640 return (
641 <View
642 style={{
643 height: 1,
644 backgroundColor: props.textStyle.dividerColor,
645 }}
646 />
647 );
648 case "blockquote":
649 return (
650 <View
651 style={{
652 borderLeftColor: props.textStyle.quoteMarkerColor,
653 borderLeftWidth: 2,
654 marginVertical: props.compact ? 4 : 0,
655 paddingLeft: 11,
656 paddingVertical: 2,
657 gap: 6,
658 }}
659 >
660 {(props.node.children ?? []).map((child, index) => (
661 <NativeMarkdownBlock
662 key={nodeKey(child, index)}
663 node={child}
664 skills={props.skills}
665 textStyle={props.textStyle}
666 highlightCode={props.highlightCode}
667 onLinkPress={props.onLinkPress}
668 depth={depth}
669 compact
670 />
671 ))}
672 </View>
673 );
674 case "list":
675 return (
676 <NativeList
677 node={props.node}
678 skills={props.skills}
679 textStyle={props.textStyle}
680 highlightCode={props.highlightCode}
681 onLinkPress={props.onLinkPress}
682 depth={depth}
683 />
684 );
685 case "paragraph":
686 return (props.node.children ?? []).some((child) => child.type === "image") ? (
687 <NativeMixedParagraph
688 node={props.node}
689 skills={props.skills}
690 textStyle={props.textStyle}
691 onLinkPress={props.onLinkPress}
692 />
693 ) : (
694 <SelectableNode
695 node={props.node}
696 skills={props.skills}
697 textStyle={props.textStyle}
698 onLinkPress={props.onLinkPress}
699 />
700 );
701 case "html_block":
702 case "math_block":
703 return (
704 <View
705 style={{
706 marginVertical: props.compact ? 2 : 0,
707 paddingHorizontal: props.node.type === "math_block" ? 10 : 0,
708 paddingVertical: props.node.type === "math_block" ? 8 : 0,
709 backgroundColor:
710 props.node.type === "math_block"
711 ? props.textStyle.codeBackgroundColor
712 : "transparent",
713 }}
714 >
715 <SelectableNode
716 node={props.node}
717 skills={props.skills}
718 textStyle={props.textStyle}
719 onLinkPress={props.onLinkPress}
720 />
721 </View>
722 );
723 case "table_head":
724 case "table_body":
725 case "table_row":
726 case "table_cell":
727 case "list_item":
728 case "task_list_item":
729 return (
730 <View style={{ gap: 4 }}>
731 {(props.node.children ?? []).map((child, index) => (
732 <NativeMarkdownBlock
733 key={nodeKey(child, index)}
734 node={child}
735 skills={props.skills}
736 textStyle={props.textStyle}
737 highlightCode={props.highlightCode}
738 onLinkPress={props.onLinkPress}
739 depth={depth}
740 compact
741 />
742 ))}
743 </View>
744 );
745 default:
746 return (
747 <SelectableNode
748 node={props.node}
749 skills={props.skills}
750 textStyle={props.textStyle}
751 onLinkPress={props.onLinkPress}
752 />
753 );
754 }
755}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx created+755
......@@ -0,0 +1,755 @@
1import { createContext, useContext, useEffect, useState } from "react";
2import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native";
3import type { MarkdownNode } from "react-native-nitro-markdown/headless";
4
5import { CopyTextButton } from "./CopyTextButton";
6import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
7import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
8import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText";
9import type {
10 MarkdownCodeHighlighter,
11 MarkdownHighlightedToken,
12 MarkdownImageRenderer,
13 NativeMarkdownTextStyle,
14 SelectableMarkdownSkill,
15} from "./SelectableMarkdownText.types";
16
17/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */
18export const MarkdownImageRendererContext = createContext<MarkdownImageRenderer | null>(null);
19
20type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
21
22const highlightedCodeCache = new Map<string, HighlightedCode>();
23const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();
24const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;
25const MONO_FONT_FAMILY = Platform.select({
26 ios: "ui-monospace",
27 android: "monospace",
28 default: "monospace",
29});
30
31function nodeKey(node: MarkdownNode, index: number): string {
32 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
33}
34
35/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */
36function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number {
37 return Math.max(10, Math.round(textStyle.fontSize * 0.8));
38}
39
40function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number {
41 return codeBlockFontSize(textStyle) + 6;
42}
43
44function nodeText(node: MarkdownNode): string {
45 if (node.content !== undefined) {
46 return node.content;
47 }
48 return (node.children ?? []).map(nodeText).join("");
49}
50
51function documentFor(node: MarkdownNode): MarkdownNode {
52 return node.type === "document" ? node : { type: "document", children: [node] };
53}
54
55function SelectableNode(props: {
56 readonly node: MarkdownNode;
57 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
58 readonly textStyle: NativeMarkdownTextStyle;
59 readonly onLinkPress?: (href: string) => void;
60}) {
61 return (
62 <NativeMarkdownSelectableText
63 runs={nativeMarkdownDocumentRuns(documentFor(props.node), props.skills)}
64 textStyle={props.textStyle}
65 onLinkPress={props.onLinkPress}
66 />
67 );
68}
69
70function codeHighlightCacheKey(
71 code: string,
72 language: string | undefined,
73 theme: "light" | "dark",
74): string {
75 return `${theme}:${language ?? "text"}:${code}`;
76}
77
78function cacheHighlightedCode(key: string, tokens: HighlightedCode): void {
79 highlightedCodeCache.delete(key);
80 highlightedCodeCache.set(key, tokens);
81
82 while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) {
83 const oldestKey = highlightedCodeCache.keys().next().value;
84 if (oldestKey === undefined) {
85 break;
86 }
87 highlightedCodeCache.delete(oldestKey);
88 }
89}
90
91function loadHighlightedCode(
92 code: string,
93 language: string | undefined,
94 theme: "light" | "dark",
95 highlightCode: MarkdownCodeHighlighter,
96): Promise<HighlightedCode> {
97 const key = codeHighlightCacheKey(code, language, theme);
98 const cached = highlightedCodeCache.get(key);
99 if (cached) {
100 return Promise.resolve(cached);
101 }
102
103 const pending = highlightedCodePromiseCache.get(key);
104 if (pending) {
105 return pending;
106 }
107
108 const promise = highlightCode({ code, language, theme })
109 .then((tokens) => {
110 cacheHighlightedCode(key, tokens);
111 highlightedCodePromiseCache.delete(key);
112 return tokens;
113 })
114 .catch((error) => {
115 highlightedCodePromiseCache.delete(key);
116 throw error;
117 });
118 highlightedCodePromiseCache.set(key, promise);
119 return promise;
120}
121
122function useHighlightedCode(
123 code: string,
124 language: string | undefined,
125 theme: "light" | "dark",
126 highlightCode: MarkdownCodeHighlighter,
127): HighlightedCode | null {
128 const key = codeHighlightCacheKey(code, language, theme);
129 const [highlighted, setHighlighted] = useState<{
130 readonly key: string;
131 readonly tokens: HighlightedCode | null;
132 }>(() => ({
133 key,
134 tokens: highlightedCodeCache.get(key) ?? null,
135 }));
136
137 useEffect(() => {
138 let active = true;
139 const cached = highlightedCodeCache.get(key);
140 if (cached) {
141 cacheHighlightedCode(key, cached);
142 setHighlighted({ key, tokens: cached });
143 return () => {
144 active = false;
145 };
146 }
147
148 void loadHighlightedCode(code, language, theme, highlightCode)
149 .then((tokens) => {
150 if (active) {
151 setHighlighted({ key, tokens });
152 }
153 })
154 .catch(() => {
155 if (active) {
156 setHighlighted({ key, tokens: null });
157 }
158 });
159 return () => {
160 active = false;
161 };
162 }, [code, highlightCode, key, language, theme]);
163
164 return highlighted.key === key ? highlighted.tokens : null;
165}
166
167function HighlightedCodeText(props: {
168 readonly content: string;
169 readonly highlighted: HighlightedCode | null;
170 readonly textStyle: NativeMarkdownTextStyle;
171}) {
172 if (!props.highlighted) {
173 return (
174 <MarkdownTextPrimitive
175 uiTextView
176 selectable
177 style={{
178 color: props.textStyle.codeColor,
179 fontFamily: MONO_FONT_FAMILY,
180 fontSize: codeBlockFontSize(props.textStyle),
181 lineHeight: codeBlockLineHeight(props.textStyle),
182 }}
183 >
184 {props.content}
185 </MarkdownTextPrimitive>
186 );
187 }
188 const highlighted = props.highlighted;
189 let sourceOffset = 0;
190 const keyOccurrences = new Map<string, number>();
191 const keyedLines = highlighted.map((line) => {
192 const lineStart = sourceOffset;
193 const tokens = line.map((token) => {
194 const start = sourceOffset;
195 sourceOffset += token.content.length;
196 const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`;
197 const occurrence = keyOccurrences.get(signature) ?? 0;
198 keyOccurrences.set(signature, occurrence + 1);
199 return { key: `${signature}:${occurrence}`, token };
200 });
201 sourceOffset += 1;
202 return {
203 key: `line:${lineStart}:${line.map((token) => token.content).join("")}`,
204 tokens,
205 };
206 });
207
208 return (
209 <MarkdownTextPrimitive
210 uiTextView
211 selectable
212 style={{
213 color: props.textStyle.codeColor,
214 fontFamily: MONO_FONT_FAMILY,
215 fontSize: codeBlockFontSize(props.textStyle),
216 lineHeight: codeBlockLineHeight(props.textStyle),
217 }}
218 >
219 {keyedLines.map((line, lineIndex) => (
220 <MarkdownTextPrimitive key={line.key}>
221 {line.tokens.map(({ key, token }) => (
222 <MarkdownTextPrimitive
223 key={key}
224 style={{
225 color: token.color ?? props.textStyle.codeColor,
226 fontFamily: MONO_FONT_FAMILY,
227 fontStyle:
228 token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal",
229 fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",
230 }}
231 >
232 {token.content}
233 </MarkdownTextPrimitive>
234 ))}
235 {lineIndex + 1 < keyedLines.length ? "\n" : ""}
236 </MarkdownTextPrimitive>
237 ))}
238 </MarkdownTextPrimitive>
239 );
240}
241
242function NativeCodeBlock(props: {
243 readonly node: MarkdownNode;
244 readonly textStyle: NativeMarkdownTextStyle;
245 readonly highlightCode: MarkdownCodeHighlighter;
246 readonly compact?: boolean;
247}) {
248 const content = nodeText(props.node).replace(/\n$/, "");
249 const colorScheme = useColorScheme();
250 const theme = colorScheme === "dark" ? "dark" : "light";
251 const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode);
252 const languageLabel = props.node.language?.toUpperCase() ?? "CODE";
253 return (
254 <View
255 style={{
256 backgroundColor: props.textStyle.codeBlockBackgroundColor,
257 borderColor: props.textStyle.dividerColor,
258 borderCurve: "continuous",
259 borderRadius: 10,
260 borderWidth: 1,
261 marginVertical: props.compact ? 7 : 0,
262 overflow: "hidden",
263 }}
264 >
265 <View
266 style={{
267 minHeight: 42,
268 borderBottomColor: props.textStyle.dividerColor,
269 borderBottomWidth: 1,
270 paddingLeft: 14,
271 paddingRight: 6,
272 flexDirection: "row",
273 alignItems: "center",
274 justifyContent: "space-between",
275 }}
276 >
277 <Text
278 selectable
279 style={{
280 flex: 1,
281 color: props.textStyle.mutedColor,
282 fontFamily: MONO_FONT_FAMILY,
283 fontSize: codeBlockFontSize(props.textStyle),
284 }}
285 >
286 {languageLabel}
287 </Text>
288 <CopyTextButton
289 accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}
290 text={content}
291 tintColor={props.textStyle.mutedColor}
292 copiedTintColor={props.textStyle.linkColor}
293 backgroundColor={props.textStyle.codeBackgroundColor}
294 borderColor={props.textStyle.dividerColor}
295 buttonSize={34}
296 iconSize={14}
297 />
298 </View>
299 <ScrollView
300 horizontal
301 bounces={false}
302 nestedScrollEnabled={Platform.OS === "android"}
303 showsHorizontalScrollIndicator={false}
304 contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}
305 >
306 <HighlightedCodeText
307 content={content}
308 highlighted={highlighted}
309 textStyle={props.textStyle}
310 />
311 </ScrollView>
312 </View>
313 );
314}
315
316function collectTableRows(node: MarkdownNode): MarkdownNode[] {
317 const rows: MarkdownNode[] = [];
318 const visit = (child: MarkdownNode) => {
319 if (child.type === "table_row") {
320 rows.push(child);
321 return;
322 }
323 for (const nested of child.children ?? []) {
324 visit(nested);
325 }
326 };
327 visit(node);
328 return rows;
329}
330
331function NativeTable(props: {
332 readonly node: MarkdownNode;
333 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
334 readonly textStyle: NativeMarkdownTextStyle;
335 readonly onLinkPress?: (href: string) => void;
336}) {
337 const rows = collectTableRows(props.node);
338 return (
339 <ScrollView
340 horizontal
341 bounces={false}
342 nestedScrollEnabled={Platform.OS === "android"}
343 showsHorizontalScrollIndicator={false}
344 >
345 <View
346 style={{
347 borderColor: props.textStyle.dividerColor,
348 borderCurve: "continuous",
349 borderRadius: 8,
350 borderWidth: 1,
351 overflow: "hidden",
352 }}
353 >
354 {rows.map((row, rowIndex) => (
355 <View
356 key={nodeKey(row, rowIndex)}
357 style={{
358 flexDirection: "row",
359 backgroundColor: rowIndex === 0 ? props.textStyle.codeBackgroundColor : "transparent",
360 borderTopColor: props.textStyle.dividerColor,
361 borderTopWidth: rowIndex === 0 ? 0 : 1,
362 }}
363 >
364 {(row.children ?? []).map((cell, cellIndex) => (
365 <View
366 key={nodeKey(cell, cellIndex)}
367 style={{
368 width: 160,
369 borderLeftColor: props.textStyle.dividerColor,
370 borderLeftWidth: cellIndex === 0 ? 0 : 1,
371 paddingHorizontal: 10,
372 paddingVertical: 8,
373 }}
374 >
375 <NativeMarkdownSelectableText
376 runs={nativeMarkdownDocumentRuns(documentFor(cell), props.skills).map((run) =>
377 rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run,
378 )}
379 textStyle={props.textStyle}
380 onLinkPress={props.onLinkPress}
381 />
382 </View>
383 ))}
384 </View>
385 ))}
386 </View>
387 </ScrollView>
388 );
389}
390
391function NativeMarkdownImage(props: {
392 readonly node: MarkdownNode;
393 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
394 readonly textStyle: NativeMarkdownTextStyle;
395 readonly onLinkPress?: (href: string) => void;
396}) {
397 const renderImage = useContext(MarkdownImageRendererContext);
398 const href = props.node.href;
399 if (!href) {
400 return (
401 <SelectableNode
402 node={props.node}
403 skills={props.skills}
404 textStyle={props.textStyle}
405 onLinkPress={props.onLinkPress}
406 />
407 );
408 }
409
410 if (renderImage) {
411 const rendered = renderImage({
412 href,
413 alt: props.node.alt ?? null,
414 title: props.node.title ?? null,
415 });
416 if (rendered != null) {
417 return <>{rendered}</>;
418 }
419 }
420
421 return (
422 <View style={{ gap: 6 }}>
423 <Image
424 source={{ uri: href }}
425 resizeMode="contain"
426 accessibilityLabel={props.node.alt ?? props.node.title}
427 style={{
428 width: "100%",
429 aspectRatio: 16 / 9,
430 backgroundColor: props.textStyle.codeBackgroundColor,
431 borderRadius: 10,
432 }}
433 />
434 {props.node.alt ? (
435 <Text
436 selectable
437 style={{
438 color: props.textStyle.mutedColor,
439 fontFamily: props.textStyle.fontFamily,
440 fontSize: 12,
441 lineHeight: 16,
442 }}
443 >
444 {props.node.alt}
445 </Text>
446 ) : null}
447 </View>
448 );
449}
450
451function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] {
452 const groups: MarkdownNode[] = [];
453 let inline: MarkdownNode[] = [];
454 const flush = () => {
455 if (inline.length === 0) {
456 return;
457 }
458 groups.push({ type: "paragraph", children: inline });
459 inline = [];
460 };
461
462 for (const node of nodes) {
463 if (node.type === "image") {
464 flush();
465 groups.push(node);
466 } else {
467 inline.push(node);
468 }
469 }
470 flush();
471 return groups;
472}
473
474function NativeMixedParagraph(props: {
475 readonly node: MarkdownNode;
476 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
477 readonly textStyle: NativeMarkdownTextStyle;
478 readonly onLinkPress?: (href: string) => void;
479}) {
480 return (
481 <View style={{ gap: 8 }}>
482 {inlineGroups(props.node.children ?? []).map((child, index) =>
483 child.type === "image" ? (
484 <NativeMarkdownImage
485 key={nodeKey(child, index)}
486 node={child}
487 skills={props.skills}
488 textStyle={props.textStyle}
489 onLinkPress={props.onLinkPress}
490 />
491 ) : (
492 <SelectableNode
493 key={nodeKey(child, index)}
494 node={child}
495 skills={props.skills}
496 textStyle={props.textStyle}
497 onLinkPress={props.onLinkPress}
498 />
499 ),
500 )}
501 </View>
502 );
503}
504
505function NativeList(props: {
506 readonly node: MarkdownNode;
507 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
508 readonly textStyle: NativeMarkdownTextStyle;
509 readonly highlightCode: MarkdownCodeHighlighter;
510 readonly onLinkPress?: (href: string) => void;
511 readonly depth: number;
512}) {
513 const ordered = props.node.ordered ?? false;
514 const start = props.node.start ?? 1;
515 const nested = props.depth > 0;
516 return (
517 <View
518 style={{
519 gap: nested ? 3 : 5,
520 }}
521 >
522 {(props.node.children ?? []).map((item, index) => {
523 const taskMarker = item.type === "task_list_item";
524 const marker = taskMarker
525 ? item.checked
526 ? "☑︎"
527 : "☐︎"
528 : ordered
529 ? `${start + index}.`
530 : props.depth % 3 === 1
531 ? "◦"
532 : props.depth % 3 === 2
533 ? "▪︎"
534 : "•";
535 const markerWidth = ordered ? 28 : taskMarker ? 20 : 18;
536 const markerOffset = taskMarker ? 3 : ordered ? 0 : 2;
537 return (
538 <View
539 key={nodeKey(item, index)}
540 style={{ alignItems: "flex-start", flexDirection: "row" }}
541 >
542 <View
543 style={{
544 width: markerWidth,
545 height: props.textStyle.lineHeight,
546 marginRight: 6,
547 alignItems: ordered ? "flex-end" : "center",
548 justifyContent: "flex-start",
549 }}
550 >
551 <Text
552 style={{
553 color: props.textStyle.color,
554 fontFamily: props.textStyle.fontFamily,
555 fontSize: taskMarker ? 14 : props.textStyle.fontSize,
556 lineHeight: props.textStyle.lineHeight,
557 fontVariant: ordered ? ["tabular-nums"] : undefined,
558 transform: [{ translateY: markerOffset }],
559 }}
560 >
561 {marker}
562 </Text>
563 </View>
564 <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
565 {nativeMarkdownListItemBlocks(item).map((child, childIndex) => (
566 <NativeMarkdownBlock
567 key={nodeKey(child, childIndex)}
568 node={child}
569 skills={props.skills}
570 textStyle={props.textStyle}
571 highlightCode={props.highlightCode}
572 onLinkPress={props.onLinkPress}
573 depth={props.depth + 1}
574 compact
575 />
576 ))}
577 </View>
578 </View>
579 );
580 })}
581 </View>
582 );
583}
584
585export function NativeMarkdownBlock(props: {
586 readonly node: MarkdownNode;
587 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
588 readonly textStyle: NativeMarkdownTextStyle;
589 readonly highlightCode: MarkdownCodeHighlighter;
590 readonly onLinkPress?: (href: string) => void;
591 readonly depth?: number;
592 readonly compact?: boolean;
593}) {
594 const depth = props.depth ?? 0;
595 switch (props.node.type) {
596 case "document":
597 return (
598 <View style={{ gap: 8 }}>
599 {(props.node.children ?? []).map((child, index) => (
600 <NativeMarkdownBlock
601 key={nodeKey(child, index)}
602 node={child}
603 skills={props.skills}
604 textStyle={props.textStyle}
605 highlightCode={props.highlightCode}
606 onLinkPress={props.onLinkPress}
607 depth={depth}
608 />
609 ))}
610 </View>
611 );
612 case "code_block":
613 return (
614 <NativeCodeBlock
615 node={props.node}
616 textStyle={props.textStyle}
617 highlightCode={props.highlightCode}
618 compact={props.compact}
619 />
620 );
621 case "table":
622 return (
623 <NativeTable
624 node={props.node}
625 skills={props.skills}
626 textStyle={props.textStyle}
627 onLinkPress={props.onLinkPress}
628 />
629 );
630 case "image":
631 return (
632 <NativeMarkdownImage
633 node={props.node}
634 skills={props.skills}
635 textStyle={props.textStyle}
636 onLinkPress={props.onLinkPress}
637 />
638 );
639 case "horizontal_rule":
640 return (
641 <View
642 style={{
643 height: 1,
644 backgroundColor: props.textStyle.dividerColor,
645 }}
646 />
647 );
648 case "blockquote":
649 return (
650 <View
651 style={{
652 borderLeftColor: props.textStyle.quoteMarkerColor,
653 borderLeftWidth: 2,
654 marginVertical: props.compact ? 4 : 0,
655 paddingLeft: 11,
656 paddingVertical: 2,
657 gap: 6,
658 }}
659 >
660 {(props.node.children ?? []).map((child, index) => (
661 <NativeMarkdownBlock
662 key={nodeKey(child, index)}
663 node={child}
664 skills={props.skills}
665 textStyle={props.textStyle}
666 highlightCode={props.highlightCode}
667 onLinkPress={props.onLinkPress}
668 depth={depth}
669 compact
670 />
671 ))}
672 </View>
673 );
674 case "list":
675 return (
676 <NativeList
677 node={props.node}
678 skills={props.skills}
679 textStyle={props.textStyle}
680 highlightCode={props.highlightCode}
681 onLinkPress={props.onLinkPress}
682 depth={depth}
683 />
684 );
685 case "paragraph":
686 return (props.node.children ?? []).some((child) => child.type === "image") ? (
687 <NativeMixedParagraph
688 node={props.node}
689 skills={props.skills}
690 textStyle={props.textStyle}
691 onLinkPress={props.onLinkPress}
692 />
693 ) : (
694 <SelectableNode
695 node={props.node}
696 skills={props.skills}
697 textStyle={props.textStyle}
698 onLinkPress={props.onLinkPress}
699 />
700 );
701 case "html_block":
702 case "math_block":
703 return (
704 <View
705 style={{
706 marginVertical: props.compact ? 2 : 0,
707 paddingHorizontal: props.node.type === "math_block" ? 10 : 0,
708 paddingVertical: props.node.type === "math_block" ? 8 : 0,
709 backgroundColor:
710 props.node.type === "math_block"
711 ? props.textStyle.codeBackgroundColor
712 : "transparent",
713 }}
714 >
715 <SelectableNode
716 node={props.node}
717 skills={props.skills}
718 textStyle={props.textStyle}
719 onLinkPress={props.onLinkPress}
720 />
721 </View>
722 );
723 case "table_head":
724 case "table_body":
725 case "table_row":
726 case "table_cell":
727 case "list_item":
728 case "task_list_item":
729 return (
730 <View style={{ gap: 4 }}>
731 {(props.node.children ?? []).map((child, index) => (
732 <NativeMarkdownBlock
733 key={nodeKey(child, index)}
734 node={child}
735 skills={props.skills}
736 textStyle={props.textStyle}
737 highlightCode={props.highlightCode}
738 onLinkPress={props.onLinkPress}
739 depth={depth}
740 compact
741 />
742 ))}
743 </View>
744 );
745 default:
746 return (
747 <SelectableNode
748 node={props.node}
749 skills={props.skills}
750 textStyle={props.textStyle}
751 onLinkPress={props.onLinkPress}
752 />
753 );
754 }
755}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx deleted-314
......@@ -1,314 +0,0 @@
1import { createContext, useCallback, useContext } from "react";
2import {
3 findNodeHandle,
4 Image,
5 Linking,
6 Platform,
7 StyleSheet,
8 Text as RNText,
9 type TextStyle,
10 useColorScheme,
11} from "react-native";
12
13import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
14import { markdownFileIconSource } from "./markdownFileIcons";
15import { markdownLinkIconSource } from "./markdownLinkIcons";
16import { resolveMarkdownLinkIcon } from "./markdownLinks";
17import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
18import type {
19 MarkdownFileContextMenu,
20 NativeMarkdownTextStyle,
21} from "./SelectableMarkdownText.types";
22import { installMarkdownCopySanitizer } from "./T3MarkdownTextSelectionModule";
23
24export interface MarkdownFileContextMenuHandlers {
25 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
26 readonly onFileContextMenuAction: (href: string, actionId: string) => void;
27}
28
29/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */
30export const MarkdownFileContextMenuContext = createContext<MarkdownFileContextMenuHandlers | null>(
31 null,
32);
33
34const EXTERNAL_LINK_PREFIX = "◉ ";
35const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
36const SKILL_ICON_PLACEHOLDER = "\uFFFC";
37const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;
38const MONO_FONT_FAMILY = Platform.select({
39 ios: "ui-monospace",
40 android: "monospace",
41 default: "monospace",
42});
43const styles = StyleSheet.create({
44 inlineIcon: {
45 width: 14,
46 height: 14,
47 marginHorizontal: 3,
48 transform: [{ translateY: 2 }],
49 },
50});
51
52function runKeySignature(run: NativeMarkdownTextRun): string {
53 return [
54 run.text,
55 run.bold,
56 run.italic,
57 run.strikethrough,
58 run.code,
59 run.href,
60 run.externalHost,
61 run.fileIcon,
62 run.skillName,
63 run.skillLabel,
64 run.role,
65 run.headingLevel,
66 run.depth,
67 run.spacing,
68 run.firstLineHeadIndent,
69 run.headIndent,
70 run.paragraphSpacing,
71 ].join(":");
72}
73
74const DEFAULT_BODY_FONT_SIZE = 15;
75const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const;
76
77function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number {
78 const index = Math.max(0, Math.min(5, headingLevel - 1));
79 const configured = textStyle.headingFontSizes?.[index];
80 if (typeof configured === "number" && Number.isFinite(configured)) {
81 return configured;
82 }
83
84 const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE;
85 return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale));
86}
87
88function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
89 const isFile = run.fileIcon != null;
90 const isSkill = run.skillName != null;
91 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
92 const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel);
93 const isHeading = run.role === "heading";
94 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
95 const hasParagraphStyle = run.headIndent !== undefined;
96 const textDecorationLine = run.strikethrough
97 ? "line-through"
98 : run.href && !isFile
99 ? "underline"
100 : "none";
101
102 return {
103 color: isFile
104 ? textStyle.fileTextColor
105 : isSkill
106 ? textStyle.skillTextColor
107 : run.href
108 ? textStyle.linkColor
109 : isHeading
110 ? textStyle.strongColor
111 : run.role === "quote-marker"
112 ? textStyle.quoteMarkerColor
113 : run.role === "divider"
114 ? textStyle.dividerColor
115 : run.role === "code-language"
116 ? textStyle.mutedColor
117 : run.role === "list-marker"
118 ? textStyle.mutedColor
119 : isCodeBlock
120 ? textStyle.codeColor
121 : run.code
122 ? textStyle.inlineCodeColor
123 : run.bold
124 ? textStyle.strongColor
125 : textStyle.color,
126 fontFamily:
127 isFile || isSkill
128 ? textStyle.boldFontFamily
129 : run.code || isCodeBlock
130 ? MONO_FONT_FAMILY
131 : isHeading
132 ? textStyle.headingFontFamily
133 : run.bold
134 ? textStyle.boldFontFamily
135 : textStyle.fontFamily,
136 fontSize:
137 run.role === "spacer"
138 ? (run.spacing ?? 10)
139 : run.role === "list-break"
140 ? textStyle.fontSize
141 : isHeading
142 ? headingFontSize
143 : run.role === "code-language"
144 ? Math.max(10, Math.round(textStyle.fontSize * 0.73))
145 : run.code || isCodeBlock
146 ? Math.max(12, textStyle.fontSize - 2)
147 : textStyle.fontSize,
148 lineHeight:
149 run.role === "spacer"
150 ? (run.spacing ?? 10)
151 : run.role === "list-break"
152 ? textStyle.lineHeight + (run.spacing ?? 0)
153 : isHeading
154 ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2)
155 : isCodeBlock
156 ? Math.max(16, textStyle.lineHeight - 2)
157 : textStyle.lineHeight,
158 fontStyle: run.italic ? "italic" : "normal",
159 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
160 textDecorationLine,
161 backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined,
162 ...(hasParagraphStyle
163 ? {
164 shadowColor: "transparent",
165 shadowOffset: {
166 width: run.firstLineHeadIndent ?? 0,
167 height: run.headIndent,
168 },
169 shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0),
170 }
171 : {}),
172 };
173}
174
175export function NativeMarkdownSelectableText(props: {
176 readonly runs: ReadonlyArray<NativeMarkdownTextRun>;
177 readonly textStyle: NativeMarkdownTextStyle;
178 readonly onLinkPress?: (href: string) => void;
179}) {
180 const colorScheme = useColorScheme();
181 const menu = useContext(MarkdownFileContextMenuContext);
182 const containsInlineIcon = props.runs.some(
183 (run) =>
184 run.fileIcon != null ||
185 (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null),
186 );
187 const attachAndroidText = useCallback(
188 (textView: RNText | null) => {
189 if (Platform.OS !== "android" || !containsInlineIcon || textView === null) {
190 return;
191 }
192 const reactTag = findNodeHandle(textView);
193 if (reactTag !== null) {
194 installMarkdownCopySanitizer(reactTag);
195 }
196 },
197 [containsInlineIcon],
198 );
199 const occurrences = new Map<string, number>();
200 const prefixedExternalLinks = new Set<string>();
201 const keyedRuns = props.runs.map((run) => {
202 const signature = runKeySignature(run);
203 const occurrence = occurrences.get(signature) ?? 0;
204 occurrences.set(signature, occurrence + 1);
205
206 let text = run.text;
207 let linkIcon = null;
208 if (run.fileIcon && Platform.OS === "ios") {
209 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
210 } else if (run.skillName && run.skillLabel) {
211 text =
212 Platform.OS === "ios"
213 ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`
214 : `$${run.skillName}`;
215 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
216 prefixedExternalLinks.add(run.href);
217 linkIcon = resolveMarkdownLinkIcon(run.externalHost);
218 if (linkIcon === null) {
219 text = `${EXTERNAL_LINK_PREFIX}${text}`;
220 } else if (Platform.OS === "ios") {
221 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
222 }
223 }
224
225 return { key: `${signature}:${occurrence}`, run, text, linkIcon };
226 });
227 // T3MarkdownText only rebuilds its attributed string during native layout. A
228 // color-only child update can otherwise leave the previous appearance cached.
229 const appearanceKey = [
230 colorScheme ?? "unspecified",
231 props.textStyle.fontSize,
232 props.textStyle.lineHeight,
233 props.textStyle.headingFontSizes?.join(","),
234 props.textStyle.color,
235 props.textStyle.strongColor,
236 props.textStyle.mutedColor,
237 props.textStyle.linkColor,
238 props.textStyle.inlineCodeColor,
239 props.textStyle.codeColor,
240 props.textStyle.codeBackgroundColor,
241 props.textStyle.codeBlockBackgroundColor,
242 props.textStyle.fileTextColor,
243 props.textStyle.skillTextColor,
244 props.textStyle.quoteMarkerColor,
245 props.textStyle.dividerColor,
246 ].join(":");
247
248 return (
249 <MarkdownTextPrimitive
250 key={appearanceKey}
251 nativeTextRef={attachAndroidText}
252 uiTextView
253 selectable
254 style={{
255 flexShrink: 1,
256 minWidth: 0,
257 color: props.textStyle.color,
258 fontFamily: props.textStyle.fontFamily,
259 fontSize: props.textStyle.fontSize,
260 lineHeight: props.textStyle.lineHeight,
261 }}
262 >
263 {keyedRuns.map(({ key, run, text, linkIcon }) => {
264 const href = run.href;
265 const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;
266 return (
267 <MarkdownTextPrimitive
268 key={key}
269 nativeID={
270 Platform.OS === "ios"
271 ? run.fileIcon
272 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
273 : run.skillName
274 ? "t3-skill:sf:cube"
275 : linkIcon
276 ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}`
277 : undefined
278 : undefined
279 }
280 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
281 style={runStyle(run, props.textStyle)}
282 onPress={
283 href
284 ? () => {
285 if (props.onLinkPress) {
286 props.onLinkPress(href);
287 } else {
288 void Linking.openURL(href);
289 }
290 }
291 : undefined
292 }
293 onContextMenuAction={
294 contextMenu && href && menu
295 ? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier)
296 : undefined
297 }
298 >
299 {Platform.OS === "android" && run.fileIcon ? (
300 <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} />
301 ) : Platform.OS === "android" && linkIcon ? (
302 <Image
303 source={markdownLinkIconSource(linkIcon)}
304 style={styles.inlineIcon}
305 tintColor={props.textStyle.linkColor}
306 />
307 ) : null}
308 {text}
309 </MarkdownTextPrimitive>
310 );
311 })}
312 </MarkdownTextPrimitive>
313 );
314}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx created+314
......@@ -0,0 +1,314 @@
1import { createContext, useCallback, useContext } from "react";
2import {
3 findNodeHandle,
4 Image,
5 Linking,
6 Platform,
7 StyleSheet,
8 Text as RNText,
9 type TextStyle,
10 useColorScheme,
11} from "react-native";
12
13import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
14import { markdownFileIconSource } from "./markdownFileIcons";
15import { markdownLinkIconSource } from "./markdownLinkIcons";
16import { resolveMarkdownLinkIcon } from "./markdownLinks";
17import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
18import type {
19 MarkdownFileContextMenu,
20 NativeMarkdownTextStyle,
21} from "./SelectableMarkdownText.types";
22import { installMarkdownCopySanitizer } from "./T3MarkdownTextSelectionModule";
23
24export interface MarkdownFileContextMenuHandlers {
25 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
26 readonly onFileContextMenuAction: (href: string, actionId: string) => void;
27}
28
29/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */
30export const MarkdownFileContextMenuContext = createContext<MarkdownFileContextMenuHandlers | null>(
31 null,
32);
33
34const EXTERNAL_LINK_PREFIX = "◉ ";
35const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
36const SKILL_ICON_PLACEHOLDER = "\uFFFC";
37const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;
38const MONO_FONT_FAMILY = Platform.select({
39 ios: "ui-monospace",
40 android: "monospace",
41 default: "monospace",
42});
43const styles = StyleSheet.create({
44 inlineIcon: {
45 width: 14,
46 height: 14,
47 marginHorizontal: 3,
48 transform: [{ translateY: 2 }],
49 },
50});
51
52function runKeySignature(run: NativeMarkdownTextRun): string {
53 return [
54 run.text,
55 run.bold,
56 run.italic,
57 run.strikethrough,
58 run.code,
59 run.href,
60 run.externalHost,
61 run.fileIcon,
62 run.skillName,
63 run.skillLabel,
64 run.role,
65 run.headingLevel,
66 run.depth,
67 run.spacing,
68 run.firstLineHeadIndent,
69 run.headIndent,
70 run.paragraphSpacing,
71 ].join(":");
72}
73
74const DEFAULT_BODY_FONT_SIZE = 15;
75const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const;
76
77function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number {
78 const index = Math.max(0, Math.min(5, headingLevel - 1));
79 const configured = textStyle.headingFontSizes?.[index];
80 if (typeof configured === "number" && Number.isFinite(configured)) {
81 return configured;
82 }
83
84 const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE;
85 return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale));
86}
87
88function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
89 const isFile = run.fileIcon != null;
90 const isSkill = run.skillName != null;
91 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
92 const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel);
93 const isHeading = run.role === "heading";
94 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
95 const hasParagraphStyle = run.headIndent !== undefined;
96 const textDecorationLine = run.strikethrough
97 ? "line-through"
98 : run.href && !isFile
99 ? "underline"
100 : "none";
101
102 return {
103 color: isFile
104 ? textStyle.fileTextColor
105 : isSkill
106 ? textStyle.skillTextColor
107 : run.href
108 ? textStyle.linkColor
109 : isHeading
110 ? textStyle.strongColor
111 : run.role === "quote-marker"
112 ? textStyle.quoteMarkerColor
113 : run.role === "divider"
114 ? textStyle.dividerColor
115 : run.role === "code-language"
116 ? textStyle.mutedColor
117 : run.role === "list-marker"
118 ? textStyle.mutedColor
119 : isCodeBlock
120 ? textStyle.codeColor
121 : run.code
122 ? textStyle.inlineCodeColor
123 : run.bold
124 ? textStyle.strongColor
125 : textStyle.color,
126 fontFamily:
127 isFile || isSkill
128 ? textStyle.boldFontFamily
129 : run.code || isCodeBlock
130 ? MONO_FONT_FAMILY
131 : isHeading
132 ? textStyle.headingFontFamily
133 : run.bold
134 ? textStyle.boldFontFamily
135 : textStyle.fontFamily,
136 fontSize:
137 run.role === "spacer"
138 ? (run.spacing ?? 10)
139 : run.role === "list-break"
140 ? textStyle.fontSize
141 : isHeading
142 ? headingFontSize
143 : run.role === "code-language"
144 ? Math.max(10, Math.round(textStyle.fontSize * 0.73))
145 : run.code || isCodeBlock
146 ? Math.max(12, textStyle.fontSize - 2)
147 : textStyle.fontSize,
148 lineHeight:
149 run.role === "spacer"
150 ? (run.spacing ?? 10)
151 : run.role === "list-break"
152 ? textStyle.lineHeight + (run.spacing ?? 0)
153 : isHeading
154 ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2)
155 : isCodeBlock
156 ? Math.max(16, textStyle.lineHeight - 2)
157 : textStyle.lineHeight,
158 fontStyle: run.italic ? "italic" : "normal",
159 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
160 textDecorationLine,
161 backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined,
162 ...(hasParagraphStyle
163 ? {
164 shadowColor: "transparent",
165 shadowOffset: {
166 width: run.firstLineHeadIndent ?? 0,
167 height: run.headIndent,
168 },
169 shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0),
170 }
171 : {}),
172 };
173}
174
175export function NativeMarkdownSelectableText(props: {
176 readonly runs: ReadonlyArray<NativeMarkdownTextRun>;
177 readonly textStyle: NativeMarkdownTextStyle;
178 readonly onLinkPress?: (href: string) => void;
179}) {
180 const colorScheme = useColorScheme();
181 const menu = useContext(MarkdownFileContextMenuContext);
182 const containsInlineIcon = props.runs.some(
183 (run) =>
184 run.fileIcon != null ||
185 (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null),
186 );
187 const attachAndroidText = useCallback(
188 (textView: RNText | null) => {
189 if (Platform.OS !== "android" || !containsInlineIcon || textView === null) {
190 return;
191 }
192 const reactTag = findNodeHandle(textView);
193 if (reactTag !== null) {
194 installMarkdownCopySanitizer(reactTag);
195 }
196 },
197 [containsInlineIcon],
198 );
199 const occurrences = new Map<string, number>();
200 const prefixedExternalLinks = new Set<string>();
201 const keyedRuns = props.runs.map((run) => {
202 const signature = runKeySignature(run);
203 const occurrence = occurrences.get(signature) ?? 0;
204 occurrences.set(signature, occurrence + 1);
205
206 let text = run.text;
207 let linkIcon = null;
208 if (run.fileIcon && Platform.OS === "ios") {
209 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
210 } else if (run.skillName && run.skillLabel) {
211 text =
212 Platform.OS === "ios"
213 ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`
214 : `$${run.skillName}`;
215 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
216 prefixedExternalLinks.add(run.href);
217 linkIcon = resolveMarkdownLinkIcon(run.externalHost);
218 if (linkIcon === null) {
219 text = `${EXTERNAL_LINK_PREFIX}${text}`;
220 } else if (Platform.OS === "ios") {
221 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
222 }
223 }
224
225 return { key: `${signature}:${occurrence}`, run, text, linkIcon };
226 });
227 // T3MarkdownText only rebuilds its attributed string during native layout. A
228 // color-only child update can otherwise leave the previous appearance cached.
229 const appearanceKey = [
230 colorScheme ?? "unspecified",
231 props.textStyle.fontSize,
232 props.textStyle.lineHeight,
233 props.textStyle.headingFontSizes?.join(","),
234 props.textStyle.color,
235 props.textStyle.strongColor,
236 props.textStyle.mutedColor,
237 props.textStyle.linkColor,
238 props.textStyle.inlineCodeColor,
239 props.textStyle.codeColor,
240 props.textStyle.codeBackgroundColor,
241 props.textStyle.codeBlockBackgroundColor,
242 props.textStyle.fileTextColor,
243 props.textStyle.skillTextColor,
244 props.textStyle.quoteMarkerColor,
245 props.textStyle.dividerColor,
246 ].join(":");
247
248 return (
249 <MarkdownTextPrimitive
250 key={appearanceKey}
251 nativeTextRef={attachAndroidText}
252 uiTextView
253 selectable
254 style={{
255 flexShrink: 1,
256 minWidth: 0,
257 color: props.textStyle.color,
258 fontFamily: props.textStyle.fontFamily,
259 fontSize: props.textStyle.fontSize,
260 lineHeight: props.textStyle.lineHeight,
261 }}
262 >
263 {keyedRuns.map(({ key, run, text, linkIcon }) => {
264 const href = run.href;
265 const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;
266 return (
267 <MarkdownTextPrimitive
268 key={key}
269 nativeID={
270 Platform.OS === "ios"
271 ? run.fileIcon
272 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
273 : run.skillName
274 ? "t3-skill:sf:cube"
275 : linkIcon
276 ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}`
277 : undefined
278 : undefined
279 }
280 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
281 style={runStyle(run, props.textStyle)}
282 onPress={
283 href
284 ? () => {
285 if (props.onLinkPress) {
286 props.onLinkPress(href);
287 } else {
288 void Linking.openURL(href);
289 }
290 }
291 : undefined
292 }
293 onContextMenuAction={
294 contextMenu && href && menu
295 ? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier)
296 : undefined
297 }
298 >
299 {Platform.OS === "android" && run.fileIcon ? (
300 <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} />
301 ) : Platform.OS === "android" && linkIcon ? (
302 <Image
303 source={markdownLinkIconSource(linkIcon)}
304 style={styles.inlineIcon}
305 tintColor={props.textStyle.linkColor}
306 />
307 ) : null}
308 {text}
309 </MarkdownTextPrimitive>
310 );
311 })}
312 </MarkdownTextPrimitive>
313 );
314}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx deleted-117
......@@ -1,117 +0,0 @@
1import { useMemo } from "react";
2import { View } from "react-native";
3import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
4
5import {
6 nativeMarkdownChunkSpacing,
7 nativeMarkdownDocumentChunks,
8 nativeMarkdownDocumentRuns,
9 nativeMarkdownWithPreservedSoftBreaks,
10} from "./nativeMarkdownText";
11import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
12import {
13 MarkdownFileContextMenuContext,
14 NativeMarkdownSelectableText,
15 type MarkdownFileContextMenuHandlers,
16} from "./NativeMarkdownSelectableText.ios";
17import type {
18 SelectableMarkdownSkill,
19 SelectableMarkdownTextProps,
20} from "./SelectableMarkdownText.types";
21
22const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = [];
23
24export type {
25 MarkdownCodeHighlighter,
26 MarkdownHighlightedToken,
27 MarkdownImageRenderer,
28 MarkdownImageRequest,
29 NativeMarkdownTextStyle,
30 SelectableMarkdownSkill,
31 SelectableMarkdownTextProps,
32} from "./SelectableMarkdownText.types";
33
34export function hasNativeSelectableMarkdownText(): boolean {
35 return true;
36}
37
38export function SelectableMarkdownText({
39 markdown,
40 skills = EMPTY_SKILLS,
41 textStyle,
42 highlightCode,
43 preserveSoftBreaks = false,
44 onLinkPress,
45 fileContextMenu,
46 onFileContextMenuAction,
47 renderImage,
48 marginTop = 0,
49 marginBottom = 0,
50}: SelectableMarkdownTextProps) {
51 const chunks = useMemo(() => {
52 const parsedDocument = parseMarkdownWithOptions(markdown, {
53 gfm: true,
54 html: true,
55 math: false,
56 });
57 const document = preserveSoftBreaks
58 ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
59 : parsedDocument;
60 return nativeMarkdownDocumentChunks(document).map((chunk) =>
61 chunk.kind === "selectable"
62 ? {
63 ...chunk,
64 runs: nativeMarkdownDocumentRuns(chunk.node, skills),
65 }
66 : chunk,
67 );
68 }, [markdown, preserveSoftBreaks, skills]);
69
70 const fileContextMenuHandlers = useMemo<MarkdownFileContextMenuHandlers | null>(
71 () =>
72 fileContextMenu && onFileContextMenuAction
73 ? { fileContextMenu, onFileContextMenuAction }
74 : null,
75 [fileContextMenu, onFileContextMenuAction],
76 );
77
78 return (
79 <MarkdownImageRendererContext.Provider value={renderImage ?? null}>
80 <MarkdownFileContextMenuContext.Provider value={fileContextMenuHandlers}>
81 {/* A percentage width here creates a cyclic intrinsic measurement inside
82 shrink-to-fit containers such as user-message bubbles. Yoga then gives
83 the native text node an unbounded second pass and the parent only clips
84 the resulting single-line width instead of reflowing it. */}
85 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
86 {chunks.map((chunk, index) => {
87 const content =
88 chunk.kind === "rich" ? (
89 <NativeMarkdownBlock
90 node={chunk.node}
91 skills={skills}
92 textStyle={textStyle}
93 highlightCode={highlightCode}
94 onLinkPress={onLinkPress}
95 />
96 ) : (
97 <NativeMarkdownSelectableText
98 runs={chunk.runs}
99 textStyle={textStyle}
100 onLinkPress={onLinkPress}
101 />
102 );
103
104 return (
105 <View
106 key={chunk.key}
107 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
108 >
109 {content}
110 </View>
111 );
112 })}
113 </View>
114 </MarkdownFileContextMenuContext.Provider>
115 </MarkdownImageRendererContext.Provider>
116 );
117}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx+105-3
......@@ -1,4 +1,25 @@
1import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";
1import { useMemo } from "react";
2import { View } from "react-native";
3import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
4
5import {
6 nativeMarkdownChunkSpacing,
7 nativeMarkdownDocumentChunks,
8 nativeMarkdownDocumentRuns,
9 nativeMarkdownWithPreservedSoftBreaks,
10} from "./nativeMarkdownText";
11import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock";
12import {
13 MarkdownFileContextMenuContext,
14 NativeMarkdownSelectableText,
15 type MarkdownFileContextMenuHandlers,
16} from "./NativeMarkdownSelectableText";
17import type {
18 SelectableMarkdownSkill,
19 SelectableMarkdownTextProps,
20} from "./SelectableMarkdownText.types";
21
22const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = [];
223
324export type {
425 MarkdownCodeHighlighter,
......@@ -10,6 +31,87 @@ export type {
1031 SelectableMarkdownTextProps,
1132} from "./SelectableMarkdownText.types";
1233
13export function SelectableMarkdownText(_props: SelectableMarkdownTextProps) {
14 return null;
34export function hasNativeSelectableMarkdownText(): boolean {
35 return true;
36}
37
38export function SelectableMarkdownText({
39 markdown,
40 skills = EMPTY_SKILLS,
41 textStyle,
42 highlightCode,
43 preserveSoftBreaks = false,
44 onLinkPress,
45 fileContextMenu,
46 onFileContextMenuAction,
47 renderImage,
48 marginTop = 0,
49 marginBottom = 0,
50}: SelectableMarkdownTextProps) {
51 const chunks = useMemo(() => {
52 const parsedDocument = parseMarkdownWithOptions(markdown, {
53 gfm: true,
54 html: true,
55 math: false,
56 });
57 const document = preserveSoftBreaks
58 ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
59 : parsedDocument;
60 return nativeMarkdownDocumentChunks(document).map((chunk) =>
61 chunk.kind === "selectable"
62 ? {
63 ...chunk,
64 runs: nativeMarkdownDocumentRuns(chunk.node, skills),
65 }
66 : chunk,
67 );
68 }, [markdown, preserveSoftBreaks, skills]);
69
70 const fileContextMenuHandlers = useMemo<MarkdownFileContextMenuHandlers | null>(
71 () =>
72 fileContextMenu && onFileContextMenuAction
73 ? { fileContextMenu, onFileContextMenuAction }
74 : null,
75 [fileContextMenu, onFileContextMenuAction],
76 );
77
78 return (
79 <MarkdownImageRendererContext.Provider value={renderImage ?? null}>
80 <MarkdownFileContextMenuContext.Provider value={fileContextMenuHandlers}>
81 {/* A percentage width here creates a cyclic intrinsic measurement inside
82 shrink-to-fit containers such as user-message bubbles. Yoga then gives
83 the native text node an unbounded second pass and the parent only clips
84 the resulting single-line width instead of reflowing it. */}
85 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
86 {chunks.map((chunk, index) => {
87 const content =
88 chunk.kind === "rich" ? (
89 <NativeMarkdownBlock
90 node={chunk.node}
91 skills={skills}
92 textStyle={textStyle}
93 highlightCode={highlightCode}
94 onLinkPress={onLinkPress}
95 />
96 ) : (
97 <NativeMarkdownSelectableText
98 runs={chunk.runs}
99 textStyle={textStyle}
100 onLinkPress={onLinkPress}
101 />
102 );
103
104 return (
105 <View
106 key={chunk.key}
107 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
108 >
109 {content}
110 </View>
111 );
112 })}
113 </View>
114 </MarkdownFileContextMenuContext.Provider>
115 </MarkdownImageRendererContext.Provider>
116 );
15117}