@vvedantb/native-ui 0.13.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -19
- 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 +19 -13
- package/src/ai/conversation.tsx +390 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +38 -1
- 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/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/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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { Pressable, ScrollView, Text, View } from "react-native";
|
|
3
3
|
import { IconArrowLeft, IconArrowRight, IconCheck, IconPencil } from "@tabler/icons-react-native";
|
|
4
|
-
import { Badge } from "../Badge";
|
|
5
4
|
import { Button } from "../Button";
|
|
6
5
|
import { Input } from "../Input";
|
|
7
|
-
import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
|
|
6
|
+
import { buttonColors, sizeStyle, textStyle, trackingStyle, typeStyle } from "../controlStyles";
|
|
8
7
|
import { palette } from "../palette";
|
|
9
8
|
import { useLargerText } from "../theme";
|
|
10
9
|
import { tokens } from "../tokens";
|
|
10
|
+
import { useReduceMotion } from "../useReduceMotion";
|
|
11
11
|
import { useScheme } from "../useScheme";
|
|
12
12
|
import { chatTextSize } from "./chat-text";
|
|
13
13
|
|
|
@@ -35,6 +35,11 @@ export type ChoiceQuestionProps = {
|
|
|
35
35
|
otherPlaceholder?: string;
|
|
36
36
|
/** Inside the web dock card (`rounded-surface bg-card p-3`, scrolling past 60% of the screen). Default `true`. */
|
|
37
37
|
framed?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Back, Next and Submit label weight. Default `medium`, the `Button` label
|
|
40
|
+
* (web `font-medium`). `semibold` for apps whose web buttons are semibold, as Eva.
|
|
41
|
+
*/
|
|
42
|
+
buttonWeight?: "medium" | "semibold";
|
|
38
43
|
};
|
|
39
44
|
|
|
40
45
|
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
@@ -72,10 +77,24 @@ export function ChoiceQuestion({
|
|
|
72
77
|
otherLabel = "Other...",
|
|
73
78
|
otherPlaceholder = "Type your answer...",
|
|
74
79
|
framed = true,
|
|
80
|
+
buttonWeight = "medium",
|
|
75
81
|
}: ChoiceQuestionProps) {
|
|
76
82
|
const scheme = useScheme();
|
|
77
83
|
const largerText = useLargerText();
|
|
78
84
|
const icon = largerText ? 18 : 15;
|
|
85
|
+
const buttonText = largerText ? "text-xl" : "text-sm";
|
|
86
|
+
// A string child draws the `Button` medium label; a semibold one is drawn here, in the variant's label colour.
|
|
87
|
+
const buttonLabel = (text: string, variant: "primary" | "ghost") =>
|
|
88
|
+
buttonWeight === "medium" ? (
|
|
89
|
+
text
|
|
90
|
+
) : (
|
|
91
|
+
<Text
|
|
92
|
+
className={`${buttonText} font-instrument-sans-semibold`}
|
|
93
|
+
style={[sizeStyle(buttonText), typeStyle("semibold"), { color: buttonColors(variant, scheme)?.color }]}
|
|
94
|
+
>
|
|
95
|
+
{text}
|
|
96
|
+
</Text>
|
|
97
|
+
);
|
|
79
98
|
const [step, setStep] = useState(0);
|
|
80
99
|
const [picked, setPicked] = useState<Record<number, string[]>>({});
|
|
81
100
|
const [other, setOther] = useState<Record<number, string>>({});
|
|
@@ -109,18 +128,13 @@ export function ChoiceQuestion({
|
|
|
109
128
|
<View className="flex-row items-center justify-between" style={{ gap: 8 }}>
|
|
110
129
|
<Text
|
|
111
130
|
className="flex-1 font-instrument-sans-semibold"
|
|
112
|
-
// Web `text-[15px] leading-snug
|
|
113
|
-
style={[{ fontSize: largerText ? 18 : 15, lineHeight: largerText ? 24 :
|
|
131
|
+
// Web `text-[15px] leading-snug` (15 / 20.625); larger text steps up to 18.
|
|
132
|
+
style={[{ fontSize: largerText ? 18 : 15, lineHeight: largerText ? 24 : 15 * 1.375 }, textStyle("foreground", scheme), typeStyle("semibold")]}
|
|
114
133
|
>
|
|
115
134
|
{question.question}
|
|
116
135
|
</Text>
|
|
117
136
|
<View className="flex-row items-center" style={{ gap: 6 }}>
|
|
118
|
-
{
|
|
119
|
-
{question.header ? (
|
|
120
|
-
<Badge variant="secondary" className="px-2 py-0.5" textClassName={largerText ? "text-sm" : "text-xs"}>
|
|
121
|
-
{question.header}
|
|
122
|
-
</Badge>
|
|
123
|
-
) : null}
|
|
137
|
+
{question.header ? <TopicBadge>{question.header}</TopicBadge> : null}
|
|
124
138
|
{total > 1 ? (
|
|
125
139
|
<Text className="font-instrument-sans-medium" style={[sizeStyle(chatTextSize("caption", largerText)), textStyle("muted", scheme), typeStyle("medium")]}>
|
|
126
140
|
{`${step + 1}/${total}`}
|
|
@@ -135,6 +149,7 @@ export function ChoiceQuestion({
|
|
|
135
149
|
key={`${option.label}-${index}`}
|
|
136
150
|
selected={!otherOn[step] && selected.includes(option.label)}
|
|
137
151
|
disabled={loading}
|
|
152
|
+
multiple={question.multiSelect}
|
|
138
153
|
mark={LETTERS[index] ?? String(index + 1)}
|
|
139
154
|
label={option.label}
|
|
140
155
|
description={option.description}
|
|
@@ -144,6 +159,7 @@ export function ChoiceQuestion({
|
|
|
144
159
|
<ChoiceRow
|
|
145
160
|
selected={otherOn[step] ?? false}
|
|
146
161
|
disabled={loading}
|
|
162
|
+
multiple={question.multiSelect}
|
|
147
163
|
mark="other"
|
|
148
164
|
label={otherLabel}
|
|
149
165
|
onPress={() => {
|
|
@@ -159,6 +175,10 @@ export function ChoiceQuestion({
|
|
|
159
175
|
placeholder={otherPlaceholder}
|
|
160
176
|
editable={!loading}
|
|
161
177
|
autoFocus
|
|
178
|
+
// Return is the Next or Submit button. Until there is an
|
|
179
|
+
// answer it keeps the keyboard up, as the button does nothing.
|
|
180
|
+
returnKeyType={isLast ? "send" : "next"}
|
|
181
|
+
submitBehavior={isLast && hasAnswer ? "blurAndSubmit" : "submit"}
|
|
162
182
|
onSubmitEditing={next}
|
|
163
183
|
/>
|
|
164
184
|
</View>
|
|
@@ -174,8 +194,9 @@ export function ChoiceQuestion({
|
|
|
174
194
|
disabled={loading}
|
|
175
195
|
onPress={() => setStep(step - 1)}
|
|
176
196
|
startContent={<IconArrowLeft size={icon} strokeWidth={2.5} color={tokens.color.foreground[scheme]} />}
|
|
197
|
+
accessibilityLabel="Back"
|
|
177
198
|
>
|
|
178
|
-
Back
|
|
199
|
+
{buttonLabel("Back", "ghost")}
|
|
179
200
|
</Button>
|
|
180
201
|
</View>
|
|
181
202
|
) : null}
|
|
@@ -184,11 +205,12 @@ export function ChoiceQuestion({
|
|
|
184
205
|
disabled={!hasAnswer}
|
|
185
206
|
loading={loading}
|
|
186
207
|
onPress={next}
|
|
208
|
+
accessibilityLabel={isLast ? "Submit" : "Next"}
|
|
187
209
|
endContent={
|
|
188
210
|
loading ? null : <IconArrowRight size={icon} strokeWidth={2.5} color={palette.primaryForeground[scheme]} />
|
|
189
211
|
}
|
|
190
212
|
>
|
|
191
|
-
{isLast ? "Submit" : "Next"}
|
|
213
|
+
{buttonLabel(isLast ? "Submit" : "Next", "primary")}
|
|
192
214
|
</Button>
|
|
193
215
|
</View>
|
|
194
216
|
</View>
|
|
@@ -205,14 +227,55 @@ export function ChoiceQuestion({
|
|
|
205
227
|
);
|
|
206
228
|
}
|
|
207
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The question's topic, the web `Badge variant="secondary"` with Eva's
|
|
232
|
+
* `text-3xs font-semibold` (10 / 14, 600) in a `px-2.5 py-0.5` pill. Drawn here
|
|
233
|
+
* because `Badge` sets its own size and weight.
|
|
234
|
+
*/
|
|
235
|
+
function TopicBadge({ children }: { children: string }) {
|
|
236
|
+
const scheme = useScheme();
|
|
237
|
+
const largerText = useLargerText();
|
|
238
|
+
const fontSize = largerText ? 14 : 10;
|
|
239
|
+
return (
|
|
240
|
+
<View
|
|
241
|
+
className="rounded-full px-2.5 py-0.5"
|
|
242
|
+
style={{ backgroundColor: palette.default[scheme], borderRadius: tokens.radius.pill }}
|
|
243
|
+
>
|
|
244
|
+
<Text
|
|
245
|
+
numberOfLines={1}
|
|
246
|
+
className="font-instrument-sans-semibold"
|
|
247
|
+
style={[
|
|
248
|
+
{ fontSize, lineHeight: largerText ? 20 : 14, color: palette.defaultForeground[scheme] },
|
|
249
|
+
typeStyle("semibold"),
|
|
250
|
+
]}
|
|
251
|
+
>
|
|
252
|
+
{children}
|
|
253
|
+
</Text>
|
|
254
|
+
</View>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The option card's `py-2 px-2.5`, which is also the slop of its press target. */
|
|
259
|
+
const CARD_PADDING = { top: 8, bottom: 8, left: 10, right: 10 };
|
|
260
|
+
|
|
261
|
+
/** A whole card is at least a 44 target: the press target inside is this much less its slop. */
|
|
262
|
+
const CARD_MIN_HEIGHT = 44;
|
|
263
|
+
|
|
264
|
+
/** The card while pressed: the kit 3% press scale, or a dim with Reduce Motion. */
|
|
265
|
+
export function choicePressStyle(pressed: boolean, reduceMotion: boolean) {
|
|
266
|
+
if (!pressed) return null;
|
|
267
|
+
return reduceMotion ? { opacity: 0.8 } : { transform: [{ scale: 0.97 }] };
|
|
268
|
+
}
|
|
269
|
+
|
|
208
270
|
/**
|
|
209
271
|
* One option row (web `Card` with `bg-secondary`, `py-2 px-2.5`, radius 16): a round 24 mark
|
|
210
272
|
* (30 with larger text; letter, or a pencil for "Other"; a check on the primary fill when picked),
|
|
211
|
-
* the label and the description. Picked: a
|
|
273
|
+
* the label and the description. Picked: a 1-wide primary ring outside, on the accent.
|
|
212
274
|
*/
|
|
213
275
|
function ChoiceRow({
|
|
214
276
|
selected,
|
|
215
277
|
disabled,
|
|
278
|
+
multiple,
|
|
216
279
|
mark,
|
|
217
280
|
label,
|
|
218
281
|
description,
|
|
@@ -221,6 +284,8 @@ function ChoiceRow({
|
|
|
221
284
|
}: {
|
|
222
285
|
selected: boolean;
|
|
223
286
|
disabled: boolean;
|
|
287
|
+
/** A multi-select question: each row is a `checkbox`, not a `radio`. */
|
|
288
|
+
multiple: boolean;
|
|
224
289
|
mark: string;
|
|
225
290
|
label: string;
|
|
226
291
|
description?: string;
|
|
@@ -229,31 +294,67 @@ function ChoiceRow({
|
|
|
229
294
|
}) {
|
|
230
295
|
const scheme = useScheme();
|
|
231
296
|
const largerText = useLargerText();
|
|
297
|
+
const reduceMotion = useReduceMotion();
|
|
298
|
+
// The scale moves the whole card, which is not the Pressable (the "Other" field sits in it too).
|
|
299
|
+
const [pressed, setPressed] = useState(false);
|
|
232
300
|
const muted = tokens.color.muted[scheme];
|
|
233
301
|
const markSize = largerText ? 30 : 24;
|
|
302
|
+
const markText = largerText ? 14 : 11;
|
|
234
303
|
const glyph = largerText ? 16 : 13;
|
|
304
|
+
const small = chatTextSize("small", largerText);
|
|
305
|
+
// Web: an option label is `text-sm leading-snug font-medium`; "Other..." is
|
|
306
|
+
// plain `text-sm`, medium only once picked.
|
|
307
|
+
const plain = mark === "other" && !selected;
|
|
308
|
+
// The web label is an inline `leading-snug` span in a 16 / 24 block, so each
|
|
309
|
+
// line box is the block's 24, not 19.25: rows are 24 a line, as on the web.
|
|
310
|
+
const labelLine = largerText ? sizeStyle(small, "snug") : { ...sizeStyle(small, "snug"), lineHeight: 24 };
|
|
235
311
|
return (
|
|
236
312
|
<View
|
|
237
|
-
style={
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
313
|
+
style={[
|
|
314
|
+
{
|
|
315
|
+
// Web `Card` (rounded-surface, 16) with `py-2 px-2.5` and no border width.
|
|
316
|
+
borderRadius: tokens.radius.lg,
|
|
317
|
+
backgroundColor: selected ? tokens.color.background[scheme] : palette.default[scheme],
|
|
318
|
+
opacity: disabled ? 0.5 : 1,
|
|
319
|
+
paddingVertical: CARD_PADDING.top,
|
|
320
|
+
paddingHorizontal: CARD_PADDING.left,
|
|
321
|
+
},
|
|
322
|
+
choicePressStyle(pressed && !disabled, reduceMotion),
|
|
323
|
+
]}
|
|
247
324
|
>
|
|
325
|
+
{selected ? (
|
|
326
|
+
// Web `ring-1 ring-primary`: a 1 line just outside the card, so picking a row moves nothing.
|
|
327
|
+
<View
|
|
328
|
+
pointerEvents="none"
|
|
329
|
+
style={{
|
|
330
|
+
position: "absolute",
|
|
331
|
+
top: -1,
|
|
332
|
+
left: -1,
|
|
333
|
+
right: -1,
|
|
334
|
+
bottom: -1,
|
|
335
|
+
borderRadius: tokens.radius.lg + 1,
|
|
336
|
+
borderWidth: 1,
|
|
337
|
+
borderColor: palette.primary[scheme],
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
) : null}
|
|
248
341
|
<Pressable
|
|
249
|
-
accessibilityRole="
|
|
342
|
+
accessibilityRole={multiple ? "checkbox" : "radio"}
|
|
250
343
|
accessibilityLabel={label}
|
|
251
|
-
|
|
344
|
+
accessibilityHint={description || undefined}
|
|
345
|
+
accessibilityState={{ checked: selected, disabled }}
|
|
252
346
|
disabled={disabled}
|
|
347
|
+
// The card's own padding: a tap anywhere on the card picks it, and stops at its edge.
|
|
348
|
+
hitSlop={CARD_PADDING}
|
|
253
349
|
onPress={onPress}
|
|
254
|
-
|
|
350
|
+
onPressIn={() => setPressed(true)}
|
|
351
|
+
onPressOut={() => setPressed(false)}
|
|
255
352
|
className="flex-row"
|
|
256
|
-
style={{
|
|
353
|
+
style={{
|
|
354
|
+
gap: 12,
|
|
355
|
+
alignItems: description ? "flex-start" : "center",
|
|
356
|
+
minHeight: CARD_MIN_HEIGHT - CARD_PADDING.top - CARD_PADDING.bottom,
|
|
357
|
+
}}
|
|
257
358
|
>
|
|
258
359
|
<View
|
|
259
360
|
style={{
|
|
@@ -272,20 +373,38 @@ function ChoiceRow({
|
|
|
272
373
|
) : mark === "other" ? (
|
|
273
374
|
<IconPencil size={glyph} color={muted} />
|
|
274
375
|
) : (
|
|
275
|
-
|
|
376
|
+
// Web `text-2xs font-bold tracking-wide`, at semibold: native-ui draws no bold face.
|
|
377
|
+
<Text
|
|
378
|
+
className="font-instrument-sans-semibold"
|
|
379
|
+
style={[
|
|
380
|
+
{ fontSize: markText, lineHeight: largerText ? 20 : 16, color: muted },
|
|
381
|
+
trackingStyle(markText, "wide"),
|
|
382
|
+
typeStyle("semibold"),
|
|
383
|
+
]}
|
|
384
|
+
>
|
|
276
385
|
{mark}
|
|
277
386
|
</Text>
|
|
278
387
|
)}
|
|
279
388
|
</View>
|
|
280
389
|
<View className="min-w-0 flex-1">
|
|
281
|
-
|
|
282
|
-
className="font-instrument-sans
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
390
|
+
{plain ? (
|
|
391
|
+
<Text className="font-instrument-sans" style={[sizeStyle(small), { color: muted }]}>
|
|
392
|
+
{label}
|
|
393
|
+
</Text>
|
|
394
|
+
) : (
|
|
395
|
+
<Text
|
|
396
|
+
className="font-instrument-sans-medium"
|
|
397
|
+
style={[labelLine, typeStyle("medium"), { color: selected ? palette.primary[scheme] : muted }]}
|
|
398
|
+
>
|
|
399
|
+
{label}
|
|
400
|
+
</Text>
|
|
401
|
+
)}
|
|
287
402
|
{description ? (
|
|
288
|
-
|
|
403
|
+
// Web `text-xs mt-0.5 leading-relaxed` (12 / 19.5).
|
|
404
|
+
<Text
|
|
405
|
+
className="font-instrument-sans"
|
|
406
|
+
style={[sizeStyle(chatTextSize("caption", largerText), "relaxed"), { color: muted, marginTop: 2 }]}
|
|
407
|
+
>
|
|
289
408
|
{description}
|
|
290
409
|
</Text>
|
|
291
410
|
) : null}
|
package/src/ai/code-block.tsx
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { ScrollView, Text, View } from "react-native";
|
|
3
|
-
import { IconCheck, IconCopy } from "@tabler/icons-react-native";
|
|
3
|
+
import { IconCheck, IconCopy, IconTextWrap } from "@tabler/icons-react-native";
|
|
4
4
|
import { cn } from "../cn";
|
|
5
5
|
import { sizeStyle, textStyle } from "../controlStyles";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
GITHUB_CODE_COLORS,
|
|
8
|
+
SCRIPT_LANGUAGES,
|
|
9
|
+
SHELL_LANGUAGES,
|
|
10
|
+
codeTokenColor,
|
|
11
|
+
loadPrismReactRenderer,
|
|
12
|
+
scriptTokens,
|
|
13
|
+
shellTokens,
|
|
14
|
+
type CodeToken,
|
|
15
|
+
} from "../highlight";
|
|
7
16
|
import { useLargerText } from "../theme";
|
|
8
17
|
import { tokens } from "../tokens";
|
|
9
18
|
import { useScheme } from "../useScheme";
|
|
10
19
|
import { MONO_FONT, chatTextSize } from "./chat-text";
|
|
11
|
-
import { ChatIconButton } from "./icon-button";
|
|
20
|
+
import { ChatIconButton, type ChatIconButtonProps, type ChatIconButtonSize } from "./icon-button";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `variant="markdown"`, the web `.markdown-code` (`MarkdownCodeBlock`): the header is `0.25rem 0.3rem 0 0.9rem` around a 28
|
|
24
|
+
* line (32 high), the code `0.25rem 0.9rem 0.8rem` at 13 with line-height
|
|
25
|
+
* 1.55, and the header actions are `icon-xs` (28) buttons.
|
|
26
|
+
*/
|
|
27
|
+
export const CODE_BLOCK_METRICS = {
|
|
28
|
+
header: { minHeight: 32, paddingTop: 4, paddingLeft: 14, paddingRight: 5 },
|
|
29
|
+
code: { paddingTop: 4, paddingHorizontal: 14, paddingBottom: 13 },
|
|
30
|
+
text: { fontSize: 13, lineHeight: 13 * 1.55 },
|
|
31
|
+
} as const;
|
|
12
32
|
|
|
13
33
|
export type CodeBlockCopyButtonProps = {
|
|
14
34
|
code: string;
|
|
@@ -23,10 +43,14 @@ export type CodeBlockCopyButtonProps = {
|
|
|
23
43
|
timeout?: number;
|
|
24
44
|
/** Default "Copy code"; "Copied" while the check shows. */
|
|
25
45
|
label?: string;
|
|
46
|
+
/** Default `sm` (the web `CodeBlock` copy button); the markdown code block uses `xs`. */
|
|
47
|
+
size?: ChatIconButtonSize;
|
|
48
|
+
/** Default `"square"`. See `ChatIconButton`. */
|
|
49
|
+
slop?: ChatIconButtonProps["slop"];
|
|
26
50
|
};
|
|
27
51
|
|
|
28
52
|
/** Ghost copy button: the copy icon, then a check for `timeout` after a copy. */
|
|
29
|
-
export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Copy code" }: CodeBlockCopyButtonProps) {
|
|
53
|
+
export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Copy code", size, slop }: CodeBlockCopyButtonProps) {
|
|
30
54
|
const [copied, setCopied] = useState(false);
|
|
31
55
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
32
56
|
|
|
@@ -48,6 +72,8 @@ export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Cop
|
|
|
48
72
|
<ChatIconButton
|
|
49
73
|
icon={copied ? IconCheck : IconCopy}
|
|
50
74
|
label={copied ? "Copied" : label}
|
|
75
|
+
size={size}
|
|
76
|
+
slop={slop}
|
|
51
77
|
onPress={() => void copy()}
|
|
52
78
|
/>
|
|
53
79
|
);
|
|
@@ -68,80 +94,194 @@ export type CodeBlockProps = {
|
|
|
68
94
|
highlight?: boolean;
|
|
69
95
|
/** With it, the header has a copy button. See `CodeBlockCopyButton`. */
|
|
70
96
|
onCopy?: (code: string) => void | Promise<void>;
|
|
97
|
+
/** Default `default`, the web `CodeBlock`. `markdown` is the web `MarkdownCodeBlock` (see `CodeBlock`). */
|
|
98
|
+
variant?: "default" | "markdown";
|
|
71
99
|
className?: string;
|
|
72
100
|
};
|
|
73
101
|
|
|
74
102
|
/**
|
|
75
|
-
* Code on the flat panel
|
|
76
|
-
*
|
|
77
|
-
* button when either is given. Long lines scroll sideways; the code is
|
|
78
|
-
* selectable. With `prism-react-renderer` installed and a `language`, tokens
|
|
103
|
+
* Code on the flat panel at radius 16, selectable, long lines scrolling
|
|
104
|
+
* sideways. With `prism-react-renderer` installed and a `language`, tokens
|
|
79
105
|
* take the GitHub colours of the scheme.
|
|
106
|
+
*
|
|
107
|
+
* `default` is the web `CodeBlock` (`ai-elements/code-block.tsx`): `text-sm`
|
|
108
|
+
* code in `p-4`, and a `px-3` header with the language and a copy button when
|
|
109
|
+
* either is given. `markdown` is the web `MarkdownCodeBlock`, which `Markdown`
|
|
110
|
+
* draws for fenced code: 13 / 20.15 code, a 32 header with `icon-xs` actions,
|
|
111
|
+
* a wrap toggle ("Wrap long lines", foreground while on) and then copy.
|
|
80
112
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Android fades the code at a side with more to scroll (there is no scroll
|
|
115
|
+
* bar), so a cut line reads as "scroll sideways". iOS ignores it.
|
|
116
|
+
*/
|
|
117
|
+
const CODE_FADING_EDGE = 24;
|
|
118
|
+
|
|
119
|
+
export function CodeBlock({ code, language, highlight = true, onCopy, variant = "default", className }: CodeBlockProps) {
|
|
120
|
+
const parts = useCodeParts(code, language, highlight, variant === "markdown");
|
|
121
|
+
if (variant === "markdown") {
|
|
122
|
+
return <MarkdownCodeBlock code={code} language={language} onCopy={onCopy} className={className} parts={parts} />;
|
|
123
|
+
}
|
|
86
124
|
const header = Boolean(language || onCopy);
|
|
87
125
|
return (
|
|
88
|
-
<View
|
|
89
|
-
className={cn("w-full overflow-hidden", className)}
|
|
90
|
-
style={{ borderRadius: tokens.radius.lg, backgroundColor: tokens.color.surfaceSecondary[scheme] }}
|
|
91
|
-
>
|
|
126
|
+
<View className={cn("w-full overflow-hidden", className)} style={parts.panel}>
|
|
92
127
|
{header ? (
|
|
93
128
|
<View className="min-h-11 flex-row items-center justify-between pl-3 pr-1.5">
|
|
94
|
-
|
|
95
|
-
{language ?? ""}
|
|
96
|
-
</Text>
|
|
129
|
+
{parts.label}
|
|
97
130
|
{onCopy ? <CodeBlockCopyButton code={code} onCopy={onCopy} /> : null}
|
|
98
131
|
</View>
|
|
99
132
|
) : null}
|
|
100
133
|
<ScrollView
|
|
101
134
|
horizontal
|
|
102
135
|
showsHorizontalScrollIndicator={false}
|
|
136
|
+
fadingEdgeLength={CODE_FADING_EDGE}
|
|
103
137
|
contentContainerClassName={header ? "px-4 pb-4" : "p-4"}
|
|
104
138
|
>
|
|
105
|
-
|
|
106
|
-
selectable
|
|
107
|
-
className={size}
|
|
108
|
-
style={[sizeStyle(size), textStyle("foreground", scheme), { fontFamily: MONO_FONT }]}
|
|
109
|
-
>
|
|
110
|
-
{highlight && language ? <HighlightedCode code={code} language={language} scheme={scheme} /> : code}
|
|
111
|
-
</Text>
|
|
139
|
+
{parts.text}
|
|
112
140
|
</ScrollView>
|
|
113
141
|
</View>
|
|
114
142
|
);
|
|
115
143
|
}
|
|
116
144
|
|
|
145
|
+
/** The web `MarkdownCodeBlock`: `CodeBlock` with `variant="markdown"`. A component of its own for the wrap state. */
|
|
146
|
+
function MarkdownCodeBlock({
|
|
147
|
+
code,
|
|
148
|
+
language,
|
|
149
|
+
onCopy,
|
|
150
|
+
className,
|
|
151
|
+
parts,
|
|
152
|
+
}: Pick<CodeBlockProps, "code" | "language" | "onCopy" | "className"> & { parts: ReturnType<typeof useCodeParts> }) {
|
|
153
|
+
const scheme = useScheme();
|
|
154
|
+
const largerText = useLargerText();
|
|
155
|
+
const [wrap, setWrap] = useState(false);
|
|
156
|
+
const header = Boolean(language || onCopy);
|
|
157
|
+
const { header: headerBox, code: codeBox } = CODE_BLOCK_METRICS;
|
|
158
|
+
const padding = header ? codeBox : { ...codeBox, paddingTop: codeBox.paddingBottom };
|
|
159
|
+
return (
|
|
160
|
+
<View className={cn("w-full overflow-hidden", className)} style={parts.panel}>
|
|
161
|
+
{header ? (
|
|
162
|
+
<View
|
|
163
|
+
className={cn("flex-row items-center justify-between gap-2", largerText && "min-h-11")}
|
|
164
|
+
style={largerText ? { paddingLeft: headerBox.paddingLeft, paddingRight: headerBox.paddingRight } : headerBox}
|
|
165
|
+
>
|
|
166
|
+
{parts.label}
|
|
167
|
+
{/* Web `.markdown-code-actions`: `gap-0.5`. The two sit 2 apart, so
|
|
168
|
+
each slops only above and below and on its outer side. */}
|
|
169
|
+
<View className="flex-row items-center gap-0.5">
|
|
170
|
+
<ChatIconButton
|
|
171
|
+
icon={IconTextWrap}
|
|
172
|
+
label={wrap ? "Scroll long lines" : "Wrap long lines"}
|
|
173
|
+
size="xs"
|
|
174
|
+
iconColor={wrap ? tokens.color.foreground[scheme] : tokens.color.muted[scheme]}
|
|
175
|
+
accessibilityState={{ selected: wrap }}
|
|
176
|
+
slop={{ left: 8, right: 0 }}
|
|
177
|
+
onPress={() => setWrap(!wrap)}
|
|
178
|
+
/>
|
|
179
|
+
{onCopy ? <CodeBlockCopyButton code={code} onCopy={onCopy} size="xs" slop={{ left: 0, right: 5 }} /> : null}
|
|
180
|
+
</View>
|
|
181
|
+
</View>
|
|
182
|
+
) : null}
|
|
183
|
+
{wrap ? (
|
|
184
|
+
<View style={padding}>{parts.text}</View>
|
|
185
|
+
) : (
|
|
186
|
+
<ScrollView
|
|
187
|
+
horizontal
|
|
188
|
+
showsHorizontalScrollIndicator={false}
|
|
189
|
+
fadingEdgeLength={CODE_FADING_EDGE}
|
|
190
|
+
contentContainerStyle={padding}
|
|
191
|
+
>
|
|
192
|
+
{parts.text}
|
|
193
|
+
</ScrollView>
|
|
194
|
+
)}
|
|
195
|
+
</View>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The panel style, the language label and the code text both code blocks draw. */
|
|
200
|
+
function useCodeParts(code: string, language: string | undefined, highlight: boolean, markdown: boolean) {
|
|
201
|
+
const scheme = useScheme();
|
|
202
|
+
const largerText = useLargerText();
|
|
203
|
+
const size = chatTextSize("small", largerText);
|
|
204
|
+
const caption = chatTextSize("caption", largerText);
|
|
205
|
+
const text = (
|
|
206
|
+
<Text
|
|
207
|
+
selectable
|
|
208
|
+
className={size}
|
|
209
|
+
style={[
|
|
210
|
+
markdown && !largerText ? CODE_BLOCK_METRICS.text : sizeStyle(size),
|
|
211
|
+
textStyle("foreground", scheme),
|
|
212
|
+
{ fontFamily: MONO_FONT },
|
|
213
|
+
]}
|
|
214
|
+
>
|
|
215
|
+
{highlight && language ? <HighlightedCode code={code} language={language} scheme={scheme} /> : code}
|
|
216
|
+
</Text>
|
|
217
|
+
);
|
|
218
|
+
return {
|
|
219
|
+
text,
|
|
220
|
+
label: (
|
|
221
|
+
<Text className={caption} style={[sizeStyle(caption), textStyle("muted", scheme), { fontFamily: MONO_FONT }]}>
|
|
222
|
+
{language ?? ""}
|
|
223
|
+
</Text>
|
|
224
|
+
),
|
|
225
|
+
panel: { borderRadius: tokens.radius.lg, backgroundColor: tokens.color.surfaceSecondary[scheme] },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
117
229
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
|
|
230
|
+
* Coloured runs as nested `Text` spans, lines joined by newlines. They
|
|
231
|
+
* inherit the size and font of the code `Text` around them.
|
|
232
|
+
*/
|
|
233
|
+
function CodeRuns({ lines, scheme }: { lines: CodeToken[][]; scheme: "light" | "dark" }) {
|
|
234
|
+
return (
|
|
235
|
+
<>
|
|
236
|
+
{lines.map((line, lineIndex) => (
|
|
237
|
+
<Fragment key={lineIndex}>
|
|
238
|
+
{lineIndex > 0 ? "\n" : null}
|
|
239
|
+
{line.map((token, tokenIndex) => (
|
|
240
|
+
<Text key={tokenIndex} style={{ color: GITHUB_CODE_COLORS[scheme][token.role] }}>
|
|
241
|
+
{token.content}
|
|
242
|
+
</Text>
|
|
243
|
+
))}
|
|
244
|
+
</Fragment>
|
|
245
|
+
))}
|
|
246
|
+
</>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The code in the web's Shiki colours: shell blocks through `shellTokens`
|
|
252
|
+
* (no peer needed), other languages through Prism, the JavaScript family
|
|
253
|
+
* recoloured where Prism and Shiki disagree (`scriptTokens`). Without
|
|
254
|
+
* `prism-react-renderer` a non-shell block is the plain code.
|
|
121
255
|
*/
|
|
122
256
|
function HighlightedCode({ code, language, scheme }: { code: string; language: string; scheme: "light" | "dark" }) {
|
|
257
|
+
const lang = language.toLowerCase();
|
|
258
|
+
const source = code.replace(/\n$/, "");
|
|
259
|
+
if (SHELL_LANGUAGES.has(lang)) return <CodeRuns lines={shellTokens(source)} scheme={scheme} />;
|
|
123
260
|
const Highlight = loadPrismReactRenderer()?.Highlight;
|
|
124
261
|
if (!Highlight) return code;
|
|
125
262
|
return (
|
|
126
|
-
<Highlight
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
263
|
+
<Highlight code={source} language={lang} theme={{ plain: { color: GITHUB_CODE_COLORS[scheme].plain }, styles: [] }}>
|
|
264
|
+
{({ tokens }) =>
|
|
265
|
+
SCRIPT_LANGUAGES.has(lang) ? (
|
|
266
|
+
<CodeRuns lines={scriptTokens(tokens)} scheme={scheme} />
|
|
267
|
+
) : (
|
|
268
|
+
<>
|
|
269
|
+
{tokens.map((line, lineIndex) => (
|
|
270
|
+
<Fragment key={lineIndex}>
|
|
271
|
+
{lineIndex > 0 ? "\n" : null}
|
|
272
|
+
{/* An empty line is one `empty` token whose content is "\n": the join above already breaks it. */}
|
|
273
|
+
{line.map((token, tokenIndex) =>
|
|
274
|
+
token.empty ? null : (
|
|
275
|
+
<Text key={tokenIndex} style={{ color: codeTokenColor(token.types, scheme) }}>
|
|
276
|
+
{token.content}
|
|
277
|
+
</Text>
|
|
278
|
+
),
|
|
279
|
+
)}
|
|
280
|
+
</Fragment>
|
|
281
|
+
))}
|
|
282
|
+
</>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
145
285
|
</Highlight>
|
|
146
286
|
);
|
|
147
287
|
}
|