@vvedantb/native-ui 0.13.0 → 0.17.1
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 +42 -21
- package/package.json +36 -3
- package/src/BottomSheetContainer.tsx +2 -2
- package/src/BulkActionBar.tsx +240 -105
- package/src/Button.tsx +11 -7
- package/src/ConfirmDialog.tsx +25 -35
- package/src/Dialog.tsx +47 -35
- package/src/DividedList.tsx +11 -7
- package/src/DropdownMenu.tsx +22 -8
- package/src/FloatingSurface.tsx +5 -2
- package/src/FloatingTabBar.tsx +13 -22
- package/src/HeaderIconButton.tsx +4 -3
- package/src/Input.tsx +2 -2
- package/src/LoadingState.tsx +132 -0
- package/src/PageTitle.tsx +9 -2
- package/src/Pager.tsx +62 -0
- package/src/PasswordInput.tsx +1 -1
- package/src/PillTabs.tsx +144 -34
- package/src/Progress.tsx +9 -7
- package/src/Select.tsx +4 -4
- package/src/Sheet.tsx +29 -22
- package/src/SheetRow.tsx +27 -9
- package/src/Skeleton.tsx +91 -9
- package/src/SurfaceRow.tsx +111 -0
- package/src/SwipeableRow.tsx +43 -0
- package/src/Switch.tsx +6 -4
- package/src/Tabs.tsx +68 -13
- package/src/Toast.tsx +107 -65
- package/src/ai/activity-step-detail.tsx +1 -3
- package/src/ai/activity-step-label.ts +981 -0
- package/src/ai/activity-step-types.ts +107 -0
- package/src/ai/activity-tasks-utils.ts +199 -0
- package/src/ai/activity-timeline.ts +45 -0
- package/src/ai/attachments.tsx +3 -1
- package/src/ai/chat-text.ts +13 -7
- package/src/ai/chat-thread.tsx +34 -27
- package/src/ai/choice-question.tsx +154 -35
- package/src/ai/code-block.tsx +189 -49
- package/src/ai/collapsible.tsx +23 -13
- package/src/ai/conversation.tsx +394 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +55 -4
- package/src/ai/message-action-sheet.tsx +4 -1
- package/src/ai/message.tsx +60 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +301 -75
- package/src/ai/shimmer.tsx +4 -6
- package/src/ai/subagent-cards.tsx +84 -14
- package/src/ai/tool-approval-card.tsx +139 -8
- package/src/ai/trace.tsx +54 -17
- package/src/ai/unified-diff.tsx +182 -0
- package/src/auth/GoogleSignInButton.tsx +6 -3
- package/src/auth/SignInScreen.tsx +11 -2
- package/src/brand/ProviderIcon.tsx +136 -0
- package/src/brand/index.ts +2 -0
- package/src/configureTheme.ts +39 -11
- package/src/controlStyles.ts +46 -30
- package/src/entries/gesture-handler.ts +4 -0
- package/src/entries/keyboard-controller.ts +4 -0
- package/src/entries/markdown.ts +1 -1
- package/src/entries/primitives.ts +24 -3
- package/src/entries/theme.ts +2 -1
- package/src/entries/video.ts +4 -0
- package/src/entries/webview.ts +6 -0
- package/src/examples/AiChatGallery.tsx +70 -0
- package/src/floating.ts +25 -4
- package/src/haptics.ts +30 -4
- package/src/highlight.ts +302 -1
- package/src/index.ts +27 -5
- package/src/loadingGrid.ts +75 -0
- package/src/markdown/Markdown.tsx +234 -32
- package/src/markdown/MarkdownTable.tsx +188 -0
- package/src/markdown/tableText.ts +58 -0
- package/src/marketing/UpgradeToProCard.tsx +4 -3
- package/src/media/ImageGallery.tsx +224 -0
- package/src/media/MediaCard.tsx +100 -0
- package/src/media/imageGrid.ts +51 -0
- package/src/motion.ts +33 -0
- package/src/pagerSwipe.ts +102 -0
- package/src/palette.ts +8 -1
- package/src/primitives/button.tsx +4 -1
- package/src/primitives/keyboard-controller.tsx +106 -0
- package/src/primitives/keyboard-parts.tsx +104 -0
- package/src/primitives/overlay.tsx +133 -45
- package/src/primitives/pager-gesture.tsx +131 -0
- package/src/primitives/sheet-drag.tsx +305 -0
- package/src/primitives/sheet-gesture.tsx +181 -0
- package/src/primitives/swipe-row-gesture.tsx +290 -0
- package/src/primitives/toast-drag-gesture.tsx +92 -0
- package/src/primitives/web-click-guard.ts +50 -0
- package/src/relativeTime.ts +94 -0
- package/src/router/RouterStackPageHeader.tsx +2 -2
- package/src/settings/SettingsEmptyState.tsx +20 -10
- package/src/settings/SettingsField.tsx +11 -5
- package/src/settings/SettingsSection.tsx +8 -5
- package/src/settings/SettingsToggleRow.tsx +55 -22
- package/src/settings/relaxedSizeStyle.ts +11 -0
- package/src/sheetDrag.ts +67 -0
- package/src/swipeRow.ts +169 -0
- package/src/tabBarRoutes.ts +18 -0
- package/src/tabIndicator.ts +71 -0
- package/src/tailwind-preset.cjs +13 -6
- package/src/tailwind-preset.d.cts +5 -0
- package/src/theme.tsx +6 -2
- package/src/toastDrag.ts +58 -0
- package/src/typography.ts +20 -3
- package/src/useScheme.ts +10 -1
- package/src/video/InlineVideo.tsx +91 -0
- package/src/webview/HtmlView.tsx +91 -0
- package/src/webview/HtmlView.web.tsx +21 -0
- package/src/webview/htmlView.ts +45 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web `LoadingState` `Drive` pixel grid (`packages/ui/src/ui/loading-state.tsx`)
|
|
3
|
+
* as plain numbers, so the timing stays testable apart from Reanimated. Each
|
|
4
|
+
* function is a worklet: the grid runs it on the UI thread every frame.
|
|
5
|
+
*
|
|
6
|
+
* Nine cells in a 3x3 grid pulse in a chevron wavefront that drives right.
|
|
7
|
+
* The 650 ms cycle is shorter than the sweep (0-360 ms of delays), so two
|
|
8
|
+
* fronts are always in flight.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One pulse of a cell, in ms: the web `PATTERNS.Drive.dur`. */
|
|
12
|
+
export const DRIVE_CYCLE_MS = 650;
|
|
13
|
+
|
|
14
|
+
/** A cell's opacity at the low and high keyframes. */
|
|
15
|
+
const LOW = 0.12;
|
|
16
|
+
const HIGH = 1;
|
|
17
|
+
/** A cell's still opacity before its delay ends: the web inline `opacity: 0.15`. */
|
|
18
|
+
export const DRIVE_IDLE_OPACITY = 0.15;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Each cell's start delay in ms, row by row: column plus distance from the
|
|
22
|
+
* middle row, times 90. The middle row leads, so the front is a chevron.
|
|
23
|
+
*/
|
|
24
|
+
export const DRIVE_DELAYS: readonly number[] = Array.from({ length: 9 }, (_, i) => {
|
|
25
|
+
const row = Math.floor(i / 3);
|
|
26
|
+
const column = i % 3;
|
|
27
|
+
return (column + Math.abs(row - 1)) * 90;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** The web `size` tokens in points: `sm` is an 11 pt grid, `default` 15 pt. */
|
|
31
|
+
export const DRIVE_SIZES = {
|
|
32
|
+
sm: { cell: 3, gap: 1, radius: 0.5 },
|
|
33
|
+
default: { cell: 4, gap: 1.5, radius: 1 },
|
|
34
|
+
} as const;
|
|
35
|
+
export type LoadingStateSize = keyof typeof DRIVE_SIZES;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The frame held with Reduce Motion: mid-sweep, so the still grid reads as
|
|
39
|
+
* the chevron (left column bright, right column dim), not nine flat cells.
|
|
40
|
+
*/
|
|
41
|
+
export const DRIVE_STILL_FRAME_MS = DRIVE_CYCLE_MS * 1.5;
|
|
42
|
+
|
|
43
|
+
/** CSS `ease-in-out`, `cubic-bezier(0.42, 0, 0.58, 1)`, at progress `x` (0-1). */
|
|
44
|
+
export function cssEaseInOut(x: number): number {
|
|
45
|
+
"worklet";
|
|
46
|
+
if (x <= 0) return 0;
|
|
47
|
+
if (x >= 1) return 1;
|
|
48
|
+
// x(t) = 3(1-t)^2 t x1 + 3(1-t) t^2 x2 + t^3 rises with t, so bisect for t.
|
|
49
|
+
const curve = (t: number, p1: number, p2: number) =>
|
|
50
|
+
3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t;
|
|
51
|
+
let lo = 0;
|
|
52
|
+
let hi = 1;
|
|
53
|
+
let t = x;
|
|
54
|
+
for (let i = 0; i < 20; i++) {
|
|
55
|
+
t = (lo + hi) / 2;
|
|
56
|
+
if (curve(t, 0.42, 0.58) < x) lo = t;
|
|
57
|
+
else hi = t;
|
|
58
|
+
}
|
|
59
|
+
return curve(t, 0, 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A cell's opacity `elapsedMs` after the grid mounted, as the web WAAPI pulse:
|
|
64
|
+
* still at 0.15 during its delay, then 0.12 -> 1 -> 0.12 every cycle, each
|
|
65
|
+
* half eased `ease-in-out`.
|
|
66
|
+
*/
|
|
67
|
+
export function driveCellOpacity(elapsedMs: number, delayMs: number): number {
|
|
68
|
+
"worklet";
|
|
69
|
+
const local = elapsedMs - delayMs;
|
|
70
|
+
if (local < 0) return DRIVE_IDLE_OPACITY;
|
|
71
|
+
const progress = (local % DRIVE_CYCLE_MS) / DRIVE_CYCLE_MS;
|
|
72
|
+
return progress < 0.5
|
|
73
|
+
? LOW + (HIGH - LOW) * cssEaseInOut(progress * 2)
|
|
74
|
+
: HIGH - (HIGH - LOW) * cssEaseInOut((progress - 0.5) * 2);
|
|
75
|
+
}
|
|
@@ -1,25 +1,23 @@
|
|
|
1
|
-
import { useContext, type ReactNode } from "react";
|
|
2
|
-
import { Text, View, type TextStyle, type ViewStyle } from "react-native";
|
|
1
|
+
import { Children, cloneElement, Fragment, isValidElement, useContext, type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
|
3
3
|
import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked";
|
|
4
4
|
import { CodeBlock } from "../ai/code-block";
|
|
5
5
|
import { MONO_FONT, chatTextSize } from "../ai/chat-text";
|
|
6
6
|
import { ChatTextColorContext } from "../ai/response-text";
|
|
7
|
-
import { sizeStyle } from "../controlStyles";
|
|
7
|
+
import { sizeStyle, trackingStyle, typeStyle } from "../controlStyles";
|
|
8
8
|
import { palette, withAlpha } from "../palette";
|
|
9
9
|
import { useLargerText } from "../theme";
|
|
10
10
|
import { tokens } from "../tokens";
|
|
11
|
+
import { fontFamily } from "../typography";
|
|
11
12
|
import { useScheme } from "../useScheme";
|
|
12
|
-
|
|
13
|
-
/** Instrument Sans faces, as the app loads them (see `INSTRUMENT_SANS` in tailwind-preset.cjs). */
|
|
14
|
-
const FONT = {
|
|
15
|
-
regular: "InstrumentSans_400Regular",
|
|
16
|
-
semibold: "InstrumentSans_600SemiBold",
|
|
17
|
-
} as const;
|
|
13
|
+
import { MARKDOWN_TABLE, MarkdownTable } from "./MarkdownTable";
|
|
18
14
|
|
|
19
15
|
/**
|
|
20
16
|
* Sizes from the web `markdown.css` (`@vvedantb/ui/tokens/css/markdown.css`),
|
|
21
17
|
* in em of the body size: h1 1.43, h2 1.29, h3 1.14, h4-h6 1; inline code
|
|
22
|
-
* 0.86. Headings line-height 1.3
|
|
18
|
+
* 0.86. Headings line-height 1.3. Blocks, headings included, are 16 apart:
|
|
19
|
+
* Streamdown's root `space-y-4` (a utility) wins over the `.markdown` block
|
|
20
|
+
* margins (a component layer), so the web draws 1rem between every block.
|
|
23
21
|
*/
|
|
24
22
|
export const MARKDOWN_SCALE = {
|
|
25
23
|
h1: 1.43,
|
|
@@ -28,7 +26,7 @@ export const MARKDOWN_SCALE = {
|
|
|
28
26
|
h4: 1,
|
|
29
27
|
inlineCode: 0.86,
|
|
30
28
|
headingLineHeight: 1.3,
|
|
31
|
-
blockGap:
|
|
29
|
+
blockGap: 16,
|
|
32
30
|
} as const;
|
|
33
31
|
|
|
34
32
|
export type MarkdownProps = {
|
|
@@ -36,8 +34,30 @@ export type MarkdownProps = {
|
|
|
36
34
|
children: string;
|
|
37
35
|
/** With it, fenced code blocks get a copy button (native-ui adds no clipboard package). */
|
|
38
36
|
onCopyCode?: (code: string) => void | Promise<void>;
|
|
37
|
+
/** With it, tables get the web Copy table button: the table as Markdown, CSV or TSV. */
|
|
38
|
+
onCopyTable?: (text: string) => void | Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Body line height. Default `relaxed`, the web `.markdown` 1.625 (14 / 22.75).
|
|
41
|
+
* `normal` is the text size's own (14 / 20), as a web `Markdown` with
|
|
42
|
+
* `text-sm` on it: Eva's user bubbles.
|
|
43
|
+
*/
|
|
44
|
+
leading?: "relaxed" | "normal";
|
|
45
|
+
/**
|
|
46
|
+
* Draws a link your way (an app's mention chip): called with the link's
|
|
47
|
+
* href and plain text; return undefined to keep the default link.
|
|
48
|
+
*/
|
|
49
|
+
renderLink?: (href: string, text: string) => ReactNode | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Heading look. Default `default`, the web `.markdown` scale. `label`
|
|
52
|
+
* demotes every heading to a quiet section label (`text-xs` medium,
|
|
53
|
+
* uppercase, `tracking-wide`, muted, 16 above and 8 below), as a digest
|
|
54
|
+
* of short themed lists (Eva's standup cards) styles its `###` headings.
|
|
55
|
+
*/
|
|
56
|
+
headings?: MarkdownHeadings;
|
|
39
57
|
};
|
|
40
58
|
|
|
59
|
+
export type MarkdownHeadings = "default" | "label";
|
|
60
|
+
|
|
41
61
|
/**
|
|
42
62
|
* Markdown at chat size, styled as the web `.markdown` (Streamdown) block:
|
|
43
63
|
* compact headings, primary links underlined at 30%, inline code on a 6%
|
|
@@ -48,13 +68,13 @@ export type MarkdownProps = {
|
|
|
48
68
|
* Needs the optional peer `react-native-marked` (it renders through
|
|
49
69
|
* `useMarkdown`, so it nests in a `ScrollView` without a `FlatList`).
|
|
50
70
|
*/
|
|
51
|
-
export function Markdown({ children, onCopyCode }: MarkdownProps) {
|
|
71
|
+
export function Markdown({ children, onCopyCode, onCopyTable, leading = "relaxed", renderLink, headings = "default" }: MarkdownProps) {
|
|
52
72
|
const scheme = useScheme();
|
|
53
73
|
const largerText = useLargerText();
|
|
54
74
|
const inherited = useContext(ChatTextColorContext);
|
|
55
|
-
const body = sizeStyle(chatTextSize("body", largerText));
|
|
75
|
+
const body = sizeStyle(chatTextSize("body", largerText), leading === "relaxed" ? "relaxed" : undefined);
|
|
56
76
|
const color = inherited ?? tokens.color.foreground[scheme];
|
|
57
|
-
const renderer = new NativeUiMarkdownRenderer(scheme, body.fontSize, onCopyCode);
|
|
77
|
+
const renderer = new NativeUiMarkdownRenderer(scheme, body.fontSize, color, onCopyCode, onCopyTable, renderLink);
|
|
58
78
|
const elements = useMarkdown(children, {
|
|
59
79
|
colorScheme: scheme,
|
|
60
80
|
renderer,
|
|
@@ -66,9 +86,12 @@ export function Markdown({ children, onCopyCode }: MarkdownProps) {
|
|
|
66
86
|
border: tokens.color.border[scheme],
|
|
67
87
|
},
|
|
68
88
|
},
|
|
69
|
-
styles: markdownStyles(scheme, body, color),
|
|
89
|
+
styles: markdownStyles(scheme, body, color, headings),
|
|
70
90
|
});
|
|
71
|
-
|
|
91
|
+
// Every block has half the gap above and below (React Native margins do not
|
|
92
|
+
// collapse), so 16 between blocks; this takes the outer halves off, as the
|
|
93
|
+
// web `first-child:mt-0` and `last-child:mb-0`.
|
|
94
|
+
return <View style={{ marginVertical: -MARKDOWN_SCALE.blockGap / 2 }}>{elements}</View>;
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
/** The style map `useMarkdown` takes, from the web scale. Exported for tests. */
|
|
@@ -76,24 +99,48 @@ export function markdownStyles(
|
|
|
76
99
|
scheme: "light" | "dark",
|
|
77
100
|
body: { fontSize: number; lineHeight: number },
|
|
78
101
|
color: string,
|
|
102
|
+
headings: MarkdownHeadings = "default",
|
|
79
103
|
): MarkedStyles {
|
|
80
|
-
const
|
|
81
|
-
fontFamily:
|
|
104
|
+
const label: TextStyle = {
|
|
105
|
+
fontFamily: fontFamily.medium,
|
|
106
|
+
...typeStyle("medium"),
|
|
107
|
+
...sizeStyle("text-xs"),
|
|
108
|
+
...trackingStyle(12, "wide"),
|
|
109
|
+
textTransform: "uppercase",
|
|
110
|
+
color: tokens.color.muted[scheme],
|
|
111
|
+
// With the half gap each block keeps (margins do not collapse): 16 above,
|
|
112
|
+
// the next block's 8 below (the web `mt-4 mb-1.5`, collapsed).
|
|
113
|
+
marginTop: MARKDOWN_SCALE.blockGap / 2,
|
|
114
|
+
marginBottom: 0,
|
|
115
|
+
paddingBottom: 0,
|
|
116
|
+
borderBottomWidth: 0,
|
|
117
|
+
};
|
|
118
|
+
const scaled = (scale: number): TextStyle => ({
|
|
119
|
+
fontFamily: fontFamily.semibold,
|
|
82
120
|
fontWeight: "600",
|
|
83
121
|
fontSize: Math.round(body.fontSize * scale),
|
|
84
122
|
lineHeight: Math.round(body.fontSize * scale * MARKDOWN_SCALE.headingLineHeight),
|
|
85
123
|
letterSpacing: -0.01 * body.fontSize * scale,
|
|
86
124
|
color,
|
|
87
|
-
|
|
88
|
-
|
|
125
|
+
marginVertical: MARKDOWN_SCALE.blockGap / 2,
|
|
126
|
+
// `react-native-marked` rules h1 and h2 off; the web has no rule.
|
|
127
|
+
paddingBottom: 0,
|
|
128
|
+
borderBottomWidth: 0,
|
|
89
129
|
});
|
|
130
|
+
const heading = (scale: number): TextStyle => (headings === "label" ? label : scaled(scale));
|
|
90
131
|
const gap = { marginVertical: MARKDOWN_SCALE.blockGap / 2 };
|
|
132
|
+
// The library's inline styles start from its own 16 / 24 text, so each one
|
|
133
|
+
// restates the body size, or a bold or linked word grows the line.
|
|
134
|
+
const inline = { fontSize: body.fontSize, lineHeight: body.lineHeight };
|
|
91
135
|
return {
|
|
92
|
-
text: { fontFamily:
|
|
136
|
+
text: { fontFamily: fontFamily.regular, ...inline, color },
|
|
93
137
|
paragraph: { ...gap, paddingVertical: 0 },
|
|
94
|
-
strong: { fontFamily:
|
|
95
|
-
em: { fontStyle: "italic" },
|
|
138
|
+
strong: { ...inline, fontFamily: fontFamily.semibold, fontWeight: "600" },
|
|
139
|
+
em: { ...inline, fontStyle: "italic" },
|
|
140
|
+
strikethrough: { ...inline, textDecorationLine: "line-through" },
|
|
96
141
|
link: {
|
|
142
|
+
...inline,
|
|
143
|
+
fontStyle: "normal",
|
|
97
144
|
color: palette.primary[scheme],
|
|
98
145
|
textDecorationLine: "underline",
|
|
99
146
|
textDecorationColor: withAlpha(palette.primary[scheme], 0.3),
|
|
@@ -104,57 +151,212 @@ export function markdownStyles(
|
|
|
104
151
|
h4: heading(MARKDOWN_SCALE.h4),
|
|
105
152
|
h5: heading(MARKDOWN_SCALE.h4),
|
|
106
153
|
h6: { ...heading(MARKDOWN_SCALE.h4), color: tokens.color.muted[scheme] },
|
|
107
|
-
li: { fontFamily:
|
|
154
|
+
li: { fontFamily: fontFamily.regular, fontSize: body.fontSize, lineHeight: body.lineHeight, color },
|
|
108
155
|
blockquote: {
|
|
109
156
|
...gap,
|
|
110
157
|
paddingLeft: 13,
|
|
111
158
|
borderLeftWidth: 2,
|
|
112
159
|
borderLeftColor: tokens.color.border[scheme],
|
|
113
160
|
backgroundColor: "transparent",
|
|
161
|
+
opacity: 1,
|
|
114
162
|
},
|
|
115
|
-
hr: {
|
|
116
|
-
codespan: inlineCodeStyle(scheme, body.fontSize),
|
|
163
|
+
hr: { ...gap, height: 1, borderBottomWidth: 0, backgroundColor: tokens.color.border[scheme] },
|
|
164
|
+
codespan: inlineCodeStyle(scheme, body.fontSize, color),
|
|
117
165
|
};
|
|
118
166
|
}
|
|
119
167
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Web `.markdown-inline-code`: 0.86em mono on a 6% foreground wash, radius 6,
|
|
170
|
+
* in the text colour (`color`, else the platform default).
|
|
171
|
+
*/
|
|
172
|
+
export function inlineCodeStyle(scheme: "light" | "dark", fontSize: number, color?: string): TextStyle {
|
|
122
173
|
return {
|
|
123
174
|
fontFamily: MONO_FONT,
|
|
124
175
|
fontStyle: "normal",
|
|
125
176
|
fontSize: Math.round(fontSize * MARKDOWN_SCALE.inlineCode),
|
|
126
177
|
backgroundColor: withAlpha(tokens.color.foreground[scheme], 0.06),
|
|
127
178
|
borderRadius: 6,
|
|
179
|
+
...(color ? { color } : null),
|
|
128
180
|
};
|
|
129
181
|
}
|
|
130
182
|
|
|
183
|
+
/**
|
|
184
|
+
* A paragraph block. A component of its own so `listItem` can find it in a
|
|
185
|
+
* loose list (the parser's own paragraph is a plain `View`).
|
|
186
|
+
*/
|
|
187
|
+
function Paragraph({ style, children }: { style?: StyleProp<ViewStyle>; children?: ReactNode }) {
|
|
188
|
+
return <View style={style}>{children}</View>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type StyledNodeProps ={ children?: ReactNode; style?: StyleProp<TextStyle>; accessibilityRole?: string };
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* An inline-code chip (see `codespan`). A component of its own so
|
|
195
|
+
* `restyleText` can find it: NativeWind's JSX swaps a `Text` made in this
|
|
196
|
+
* file for its own wrapper, so the type no longer equals `Text`.
|
|
197
|
+
*/
|
|
198
|
+
function InlineCode({ style, children }: { style?: StyleProp<TextStyle>; children: string }) {
|
|
199
|
+
return (
|
|
200
|
+
<Text selectable style={style}>
|
|
201
|
+
{children}
|
|
202
|
+
</Text>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `node` with `map` applied to the style of every `Text` in it. The parser
|
|
208
|
+
* bakes the text style into each `Text`, and React Native text takes no
|
|
209
|
+
* style from a parent view, so a block that restyles its text (a quote, a
|
|
210
|
+
* table) rewrites it here. Links keep their own style.
|
|
211
|
+
*/
|
|
212
|
+
function restyleText(node: ReactNode, map: (style: TextStyle) => TextStyle): ReactNode {
|
|
213
|
+
return Children.map(node, (child) => {
|
|
214
|
+
if (!isValidElement<StyledNodeProps>(child)) return child;
|
|
215
|
+
if (child.props.accessibilityRole === "link") return child;
|
|
216
|
+
const children = child.props.children === undefined ? undefined : restyleText(child.props.children, map);
|
|
217
|
+
if (child.type === Text || child.type === InlineCode) {
|
|
218
|
+
return cloneElement(child, { style: map(StyleSheet.flatten(child.props.style) ?? {}) }, children);
|
|
219
|
+
}
|
|
220
|
+
return children === undefined ? child : cloneElement(child, {}, children);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A link's text with its styling stripped: the strings in it, in order. */
|
|
225
|
+
function plainText(node: ReactNode): string {
|
|
226
|
+
let out = "";
|
|
227
|
+
Children.forEach(node, (child) => {
|
|
228
|
+
if (typeof child === "string" || typeof child === "number") out += String(child);
|
|
229
|
+
else if (isValidElement<StyledNodeProps>(child)) out += plainText(child.props.children);
|
|
230
|
+
});
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A cell's plain text, as the web reads a cell's `textContent` for Copy
|
|
236
|
+
* table: inline code without the no-break spaces that pad its chip.
|
|
237
|
+
*/
|
|
238
|
+
function cellText(cell: ReactNode): string {
|
|
239
|
+
return nodeText(cell).trim();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function nodeText(node: ReactNode): string {
|
|
243
|
+
return Children.toArray(node)
|
|
244
|
+
.map((child) => {
|
|
245
|
+
if (typeof child === "string" || typeof child === "number") return String(child);
|
|
246
|
+
if (!isValidElement<StyledNodeProps>(child)) return "";
|
|
247
|
+
const text = nodeText(child.props.children);
|
|
248
|
+
return child.type === InlineCode ? text.replace(/^\u00A0|\u00A0$/g, "") : text;
|
|
249
|
+
})
|
|
250
|
+
.join("");
|
|
251
|
+
}
|
|
252
|
+
|
|
131
253
|
/** Web `.markdown` lists: 1.25rem (20) indent, 0.25rem (4) between items, muted markers (a 5-wide disc). */
|
|
132
254
|
export const MARKDOWN_LIST = { indent: 20, itemGap: 4, disc: 5 } as const;
|
|
133
255
|
|
|
134
256
|
/**
|
|
135
257
|
* `react-native-marked`'s renderer with native-ui code and lists: `CodeBlock`
|
|
136
258
|
* panels, inline-code chips, and lists drawn as the web `.markdown` lists
|
|
137
|
-
* (the library's own markers apply its marker style to every item).
|
|
259
|
+
* (the library's own markers apply its marker style to every item). Links,
|
|
260
|
+
* quotes and tables are drawn here too.
|
|
138
261
|
*/
|
|
139
262
|
class NativeUiMarkdownRenderer extends Renderer {
|
|
140
263
|
constructor(
|
|
141
264
|
private readonly scheme: "light" | "dark",
|
|
142
265
|
private readonly fontSize: number,
|
|
266
|
+
private readonly color: string,
|
|
143
267
|
private readonly onCopyCode: MarkdownProps["onCopyCode"],
|
|
268
|
+
private readonly onCopyTable: MarkdownProps["onCopyTable"],
|
|
269
|
+
private readonly renderLink: MarkdownProps["renderLink"],
|
|
144
270
|
) {
|
|
145
271
|
super();
|
|
146
272
|
}
|
|
147
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Web `.markdown-link`, medium weight. The parser gives a link the
|
|
276
|
+
* paragraph's regular face, so the face is set here, on the link and the
|
|
277
|
+
* text in it.
|
|
278
|
+
*/
|
|
279
|
+
override link(children: string | ReactNode[], href: string, styles?: TextStyle, title?: string): ReactNode {
|
|
280
|
+
const custom = this.renderLink?.(href, plainText(children));
|
|
281
|
+
if (custom !== undefined) return <Fragment key={this.getKey()}>{custom}</Fragment>;
|
|
282
|
+
const medium = (style: TextStyle): TextStyle => ({ ...style, fontFamily: fontFamily.medium, ...typeStyle("medium") });
|
|
283
|
+
const content = typeof children === "string" ? children : Children.toArray(restyleText(children, medium));
|
|
284
|
+
return super.link(content, href, medium(styles ?? {}), title);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Web `.markdown blockquote`: the left rule, and the text in the muted colour. */
|
|
288
|
+
override blockquote(children: ReactNode[], styles?: ViewStyle): ReactNode {
|
|
289
|
+
const muted = tokens.color.muted[this.scheme];
|
|
290
|
+
// The inner blocks' outer margins come off, as web margins collapse into the quote's.
|
|
291
|
+
return (
|
|
292
|
+
<View key={this.getKey()} style={styles}>
|
|
293
|
+
<View style={{ marginVertical: -MARKDOWN_SCALE.blockGap / 2 }}>
|
|
294
|
+
{restyleText(children, (style) => ({ ...style, color: muted }))}
|
|
295
|
+
</View>
|
|
296
|
+
</View>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The web table on its panel (`MarkdownTable`): header cells semibold,
|
|
302
|
+
* rows split by a 70% border, and the table actions above it.
|
|
303
|
+
*/
|
|
304
|
+
override table(header: ReactNode[][], rows: ReactNode[][][]): ReactNode {
|
|
305
|
+
const { fontScale, lineHeight } = MARKDOWN_TABLE;
|
|
306
|
+
const small = (style: TextStyle): TextStyle => {
|
|
307
|
+
const size = typeof style.fontSize === "number" ? style.fontSize * fontScale : this.fontSize * fontScale;
|
|
308
|
+
// Body text takes the table line height; inline code keeps its own.
|
|
309
|
+
return style.fontFamily === MONO_FONT
|
|
310
|
+
? { ...style, fontSize: size }
|
|
311
|
+
: { ...style, fontSize: size, lineHeight: size * lineHeight };
|
|
312
|
+
};
|
|
313
|
+
const semibold = (style: TextStyle): TextStyle => ({ ...small(style), fontFamily: fontFamily.semibold, ...typeStyle("semibold") });
|
|
314
|
+
return (
|
|
315
|
+
<View key={this.getKey()} style={{ marginVertical: MARKDOWN_SCALE.blockGap / 2 }}>
|
|
316
|
+
<MarkdownTable
|
|
317
|
+
header={header.map((cell) => restyleText(cell, semibold))}
|
|
318
|
+
rows={rows.map((cells) => cells.map((cell) => restyleText(cell, small)))}
|
|
319
|
+
data={{ headers: header.map(cellText), rows: rows.map((cells) => cells.map(cellText)) }}
|
|
320
|
+
color={this.color}
|
|
321
|
+
onCopy={this.onCopyTable}
|
|
322
|
+
/>
|
|
323
|
+
</View>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
override paragraph(children: ReactNode[], styles?: ViewStyle): ReactNode {
|
|
328
|
+
return (
|
|
329
|
+
<Paragraph key={this.getKey()} style={styles}>
|
|
330
|
+
{children}
|
|
331
|
+
</Paragraph>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A loose list (items a blank line apart, as Eva's doc editor writes them)
|
|
337
|
+
* puts each item's text in a paragraph. Its outer margins come off, as in
|
|
338
|
+
* a quote, so the text sits on the marker's line, as the tight list.
|
|
339
|
+
*/
|
|
340
|
+
override listItem(children: ReactNode[], styles?: ViewStyle): ReactNode {
|
|
341
|
+
const loose = children.some((child) => isValidElement(child) && child.type === Paragraph);
|
|
342
|
+
if (!loose) return super.listItem(children, styles);
|
|
343
|
+
return (
|
|
344
|
+
<View key={this.getKey()} style={styles}>
|
|
345
|
+
<View style={{ marginVertical: -MARKDOWN_SCALE.blockGap / 2 }}>{children}</View>
|
|
346
|
+
</View>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
148
350
|
override code(text: string, language?: string): ReactNode {
|
|
149
|
-
return <CodeBlock key={this.getKey()} code={text} language={language || undefined} onCopy={this.onCopyCode} />;
|
|
351
|
+
return <CodeBlock key={this.getKey()} variant="markdown" code={text} language={language || undefined} onCopy={this.onCopyCode} />;
|
|
150
352
|
}
|
|
151
353
|
|
|
152
354
|
override codespan(text: string): ReactNode {
|
|
153
355
|
// Text in Text takes no padding: no-break spaces pad the chip and keep it on one line.
|
|
154
356
|
return (
|
|
155
|
-
<
|
|
357
|
+
<InlineCode key={this.getKey()} style={inlineCodeStyle(this.scheme, this.fontSize, this.color)}>
|
|
156
358
|
{`\u00A0${text}\u00A0`}
|
|
157
|
-
</
|
|
359
|
+
</InlineCode>
|
|
158
360
|
);
|
|
159
361
|
}
|
|
160
362
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { Modal, ScrollView, Text, View } from "react-native";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import { IconCopy, IconMaximize, IconX } from "@tabler/icons-react-native";
|
|
5
|
+
import { ChatIconButton } from "../ai/icon-button";
|
|
6
|
+
import { withAlpha } from "../palette";
|
|
7
|
+
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle } from "../Sheet";
|
|
8
|
+
import { SheetRow } from "../SheetRow";
|
|
9
|
+
import { tokens } from "../tokens";
|
|
10
|
+
import { useReduceMotion } from "../useReduceMotion";
|
|
11
|
+
import { useScheme } from "../useScheme";
|
|
12
|
+
import {
|
|
13
|
+
markdownTableColumnWidths,
|
|
14
|
+
markdownTableText,
|
|
15
|
+
type MarkdownTableData,
|
|
16
|
+
type MarkdownTableFormat,
|
|
17
|
+
} from "./tableText";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Web `.markdown table` and its Streamdown wrapper: text at 0.93em with
|
|
21
|
+
* line-height 1.5, cells `0.45rem 0.75rem`, header cells semibold, rows split
|
|
22
|
+
* by the border at 70% (the header's in the full border), the table 0.65rem
|
|
23
|
+
* inside the code-block panel (padding `0.25rem 0.3rem 0.4rem`), below the
|
|
24
|
+
* row of table actions (`flex justify-end gap-1`, 24 buttons, then `gap-2`).
|
|
25
|
+
*/
|
|
26
|
+
export const MARKDOWN_TABLE = {
|
|
27
|
+
fontScale: 0.93,
|
|
28
|
+
lineHeight: 1.5,
|
|
29
|
+
cellPaddingX: 12,
|
|
30
|
+
cellPaddingY: 7,
|
|
31
|
+
panelPadding: { top: 4, x: 5, bottom: 6 },
|
|
32
|
+
tableMargin: 10,
|
|
33
|
+
/** The web action row: 24 buttons in a 27 row. */
|
|
34
|
+
actionsHeight: 27,
|
|
35
|
+
/**
|
|
36
|
+
* The web buttons sit 4 apart; here 16, so each slops to 44 without
|
|
37
|
+
* taking its neighbour's taps.
|
|
38
|
+
*/
|
|
39
|
+
actionsGap: 16,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The full-screen table sizes each column to its text, as the web table does
|
|
44
|
+
* (`markdownTableColumnWidths`). 8.5 a character fits the 12 mono of inline
|
|
45
|
+
* code with its chip padding, so a code cell does not wrap; body text is narrower.
|
|
46
|
+
*/
|
|
47
|
+
const FULL_SCREEN_COLUMN = { charWidth: 8.5, min: 72, max: 320 } as const;
|
|
48
|
+
|
|
49
|
+
/** The web Copy table menu, in its order. */
|
|
50
|
+
const COPY_FORMATS: ReadonlyArray<{ format: MarkdownTableFormat; title: string }> = [
|
|
51
|
+
{ format: "markdown", title: "Markdown" },
|
|
52
|
+
{ format: "csv", title: "CSV" },
|
|
53
|
+
{ format: "tsv", title: "TSV" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export type MarkdownTableProps = {
|
|
57
|
+
/** Header cells, already styled. */
|
|
58
|
+
header: ReactNode[];
|
|
59
|
+
/** Body rows of cells, already styled. */
|
|
60
|
+
rows: ReactNode[][];
|
|
61
|
+
/** The same cells as plain text, for copying and for the full-screen column widths. */
|
|
62
|
+
data: MarkdownTableData;
|
|
63
|
+
/** The cell text colour. */
|
|
64
|
+
color: string;
|
|
65
|
+
/** With it, a Copy table button: a sheet of the web formats (Markdown, CSV, TSV). */
|
|
66
|
+
onCopy?: (text: string) => void | Promise<void>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The cells as rows. `widths` gives every column its own width (the
|
|
71
|
+
* full-screen view, which scrolls sideways); without it the columns share the
|
|
72
|
+
* width equally and the cells wrap, so they line up in a chat column.
|
|
73
|
+
*/
|
|
74
|
+
function TableRows({ header, rows, color, widths }: Pick<MarkdownTableProps, "header" | "rows" | "color"> & { widths?: number[] }) {
|
|
75
|
+
const scheme = useScheme();
|
|
76
|
+
const { cellPaddingX, cellPaddingY } = MARKDOWN_TABLE;
|
|
77
|
+
// The header row is ruled off in the border colour, body rows at 70%.
|
|
78
|
+
const rule = (index: number) => (index === 1 ? tokens.color.border[scheme] : withAlpha(tokens.color.border[scheme], 0.7));
|
|
79
|
+
const row = (cells: ReactNode[], index: number) => (
|
|
80
|
+
<View key={index} style={{ flexDirection: "row", borderTopWidth: index > 0 ? 1 : 0, borderTopColor: rule(index) }}>
|
|
81
|
+
{cells.map((cell, cellIndex) => (
|
|
82
|
+
<View
|
|
83
|
+
key={cellIndex}
|
|
84
|
+
style={[{ paddingHorizontal: cellPaddingX, paddingVertical: cellPaddingY }, widths ? { width: widths[cellIndex] } : { flex: 1 }]}
|
|
85
|
+
>
|
|
86
|
+
<Text style={{ color }}>{cell}</Text>
|
|
87
|
+
</View>
|
|
88
|
+
))}
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
{row(header, 0)}
|
|
94
|
+
{rows.map((cells, index) => row(cells, index + 1))}
|
|
95
|
+
</>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A markdown table on the web's panel, with the web table actions a phone
|
|
101
|
+
* can use: Copy table (a sheet of Markdown, CSV and TSV, as the web menu)
|
|
102
|
+
* and View fullscreen (the table at its own column widths, scrolling
|
|
103
|
+
* sideways, for a table too wide for the chat). The web's Download table is
|
|
104
|
+
* left out: a phone saves a file through a share sheet, which native-ui does
|
|
105
|
+
* not bundle, and the copy formats carry the same text.
|
|
106
|
+
*/
|
|
107
|
+
export function MarkdownTable({ header, rows, data, color, onCopy }: MarkdownTableProps) {
|
|
108
|
+
const scheme = useScheme();
|
|
109
|
+
const insets = useSafeAreaInsets();
|
|
110
|
+
const reduceMotion = useReduceMotion();
|
|
111
|
+
const [copyOpen, setCopyOpen] = useState(false);
|
|
112
|
+
const [fullScreen, setFullScreen] = useState(false);
|
|
113
|
+
const { panelPadding, tableMargin, actionsHeight, actionsGap } = MARKDOWN_TABLE;
|
|
114
|
+
const muted = tokens.color.muted[scheme];
|
|
115
|
+
const panel = {
|
|
116
|
+
borderRadius: tokens.radius.lg,
|
|
117
|
+
backgroundColor: tokens.color.surfaceSecondary[scheme],
|
|
118
|
+
paddingTop: panelPadding.top,
|
|
119
|
+
paddingHorizontal: panelPadding.x,
|
|
120
|
+
paddingBottom: panelPadding.bottom,
|
|
121
|
+
};
|
|
122
|
+
const copy = async (format: MarkdownTableFormat) => {
|
|
123
|
+
setCopyOpen(false);
|
|
124
|
+
await onCopy?.(markdownTableText(data, format));
|
|
125
|
+
};
|
|
126
|
+
const copyButton = onCopy ? (
|
|
127
|
+
<ChatIconButton icon={IconCopy} label="Copy table" size="xs" iconColor={muted} onPress={() => setCopyOpen(true)} />
|
|
128
|
+
) : null;
|
|
129
|
+
const widths = markdownTableColumnWidths(data, { ...FULL_SCREEN_COLUMN, padding: MARKDOWN_TABLE.cellPaddingX * 2 });
|
|
130
|
+
return (
|
|
131
|
+
<View style={panel}>
|
|
132
|
+
<View className="flex-row items-center justify-end" style={{ height: actionsHeight, gap: actionsGap }}>
|
|
133
|
+
{copyButton}
|
|
134
|
+
<ChatIconButton icon={IconMaximize} label="View fullscreen" size="xs" iconColor={muted} onPress={() => setFullScreen(true)} />
|
|
135
|
+
</View>
|
|
136
|
+
<View style={{ marginTop: tableMargin, marginBottom: tableMargin }}>
|
|
137
|
+
<TableRows header={header} rows={rows} color={color} />
|
|
138
|
+
</View>
|
|
139
|
+
{onCopy ? (
|
|
140
|
+
<Sheet open={copyOpen} onOpenChange={setCopyOpen}>
|
|
141
|
+
<SheetContent className="gap-3 px-3">
|
|
142
|
+
<SheetHeader className="px-2">
|
|
143
|
+
<SheetTitle>Copy table</SheetTitle>
|
|
144
|
+
</SheetHeader>
|
|
145
|
+
<SheetBody contentContainerClassName="gap-3">
|
|
146
|
+
{COPY_FORMATS.map(({ format, title }) => (
|
|
147
|
+
<SheetRow key={format} title={title} onPress={() => void copy(format)} />
|
|
148
|
+
))}
|
|
149
|
+
</SheetBody>
|
|
150
|
+
</SheetContent>
|
|
151
|
+
</Sheet>
|
|
152
|
+
) : null}
|
|
153
|
+
<Modal
|
|
154
|
+
visible={fullScreen}
|
|
155
|
+
animationType={reduceMotion ? "none" : "fade"}
|
|
156
|
+
statusBarTranslucent
|
|
157
|
+
onRequestClose={() => setFullScreen(false)}
|
|
158
|
+
>
|
|
159
|
+
<View
|
|
160
|
+
className="flex-1"
|
|
161
|
+
accessibilityViewIsModal
|
|
162
|
+
style={{
|
|
163
|
+
backgroundColor: tokens.color.background[scheme],
|
|
164
|
+
paddingTop: insets.top,
|
|
165
|
+
paddingBottom: insets.bottom,
|
|
166
|
+
paddingLeft: insets.left,
|
|
167
|
+
paddingRight: insets.right,
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{/* Web: the actions top right, Exit fullscreen last. */}
|
|
171
|
+
<View className="flex-row items-center justify-end px-2 py-1.5" style={{ gap: actionsGap }}>
|
|
172
|
+
{copyButton}
|
|
173
|
+
<ChatIconButton icon={IconX} label="Exit fullscreen" iconColor={muted} onPress={() => setFullScreen(false)} />
|
|
174
|
+
</View>
|
|
175
|
+
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
|
176
|
+
<ScrollView horizontal contentContainerStyle={{ flexGrow: 1 }}>
|
|
177
|
+
<View style={panel}>
|
|
178
|
+
<View style={{ marginVertical: tableMargin }}>
|
|
179
|
+
<TableRows header={header} rows={rows} color={color} widths={widths} />
|
|
180
|
+
</View>
|
|
181
|
+
</View>
|
|
182
|
+
</ScrollView>
|
|
183
|
+
</ScrollView>
|
|
184
|
+
</View>
|
|
185
|
+
</Modal>
|
|
186
|
+
</View>
|
|
187
|
+
);
|
|
188
|
+
}
|