@vvedantb/ui 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/package.json +124 -4
- package/src/ai-elements/artifact.tsx +99 -0
- package/src/ai-elements/attachments.tsx +366 -0
- package/src/ai-elements/chain-of-thought.tsx +212 -0
- package/src/ai-elements/code-block.tsx +478 -0
- package/src/ai-elements/conversation.tsx +157 -0
- package/src/ai-elements/image.tsx +25 -0
- package/src/ai-elements/inline-citation.tsx +252 -0
- package/src/ai-elements/message.tsx +269 -0
- package/src/ai-elements/model-selector.tsx +211 -0
- package/src/ai-elements/prompt-input.tsx +1018 -0
- package/src/ai-elements/queue.tsx +251 -0
- package/src/ai-elements/reasoning.tsx +178 -0
- package/src/ai-elements/shimmer.tsx +56 -0
- package/src/ai-elements/sources.tsx +77 -0
- package/src/ai-elements/speech-input.tsx +290 -0
- package/src/ai-elements/suggestion.tsx +191 -0
- package/src/css.d.ts +2 -0
- package/src/index.ts +96 -0
- package/src/motion/presets.ts +35 -0
- package/src/motion/presets.vibot.ts +105 -0
- package/src/ui/_menu-classes.ts +30 -0
- package/src/ui/_menu-classes.vibot.ts +33 -0
- package/src/ui/accordion.tsx +81 -0
- package/src/ui/accordion.verve.tsx +56 -0
- package/src/ui/badge.tsx +34 -0
- package/src/ui/breadcrumb.tsx +83 -0
- package/src/ui/button-group.tsx +84 -0
- package/src/ui/button.tsx +57 -0
- package/src/ui/card.tsx +27 -0
- package/src/ui/carousel.tsx +211 -0
- package/src/ui/checkbox.tsx +27 -0
- package/src/ui/clear-input.tsx +170 -0
- package/src/ui/clear-input.vibot.tsx +76 -0
- package/src/ui/clearInputDissolve.ts +214 -0
- package/src/ui/collapsible.tsx +7 -0
- package/src/ui/command.tsx +148 -0
- package/src/ui/context-menu.tsx +173 -0
- package/src/ui/dialog.tsx +149 -0
- package/src/ui/dropdown-menu.tsx +178 -0
- package/src/ui/hover-card.tsx +31 -0
- package/src/ui/input-group.tsx +162 -0
- package/src/ui/input-group.verve.tsx +150 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/kbd.tsx +18 -0
- package/src/ui/label.tsx +20 -0
- package/src/ui/labeled-switch-row.tsx +53 -0
- package/src/ui/modalTransition.ts +85 -0
- package/src/ui/pagination.tsx +47 -0
- package/src/ui/popover.tsx +33 -0
- package/src/ui/progress.tsx +25 -0
- package/src/ui/radix-select.tsx +202 -0
- package/src/ui/scroll-area.tsx +55 -0
- package/src/ui/scroll-area.verve.tsx +43 -0
- package/src/ui/search-input.tsx +29 -0
- package/src/ui/select.tsx +163 -0
- package/src/ui/separator.tsx +28 -0
- package/src/ui/sheet.tsx +87 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/sonner.css +16 -0
- package/src/ui/sonner.tsx +47 -0
- package/src/ui/spinner.tsx +27 -0
- package/src/ui/surface-classes.ts +55 -0
- package/src/ui/switch.tsx +26 -0
- package/src/ui/table.tsx +123 -0
- package/src/ui/tabs.tsx +140 -0
- package/src/ui/tabsSliding.ts +56 -0
- package/src/ui/textarea.tsx +21 -0
- package/src/ui/time-picker.tsx +170 -0
- package/src/ui/tooltip.tsx +25 -0
- package/src/ui/visually-hidden.tsx +3 -0
- package/src/utils/cn.ts +16 -0
- package/src/utils/ref.ts +10 -0
- package/README.md +0 -3
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { TablerIcon } from "@tabler/icons-react";
|
|
3
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
4
|
+
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
5
|
+
import {
|
|
6
|
+
IconBrain,
|
|
7
|
+
IconChevronDown,
|
|
8
|
+
IconPointFilled,
|
|
9
|
+
} from "@tabler/icons-react";
|
|
10
|
+
import { Badge } from "../ui/badge";
|
|
11
|
+
import {
|
|
12
|
+
Collapsible,
|
|
13
|
+
CollapsibleContent,
|
|
14
|
+
CollapsibleTrigger,
|
|
15
|
+
} from "../ui/collapsible";
|
|
16
|
+
import { cn } from "../utils/cn";
|
|
17
|
+
import { flatPanelSubtleClass } from "../ui/surface-classes";
|
|
18
|
+
import { createContext, useContext } from "react";
|
|
19
|
+
interface ChainOfThoughtContextValue {
|
|
20
|
+
isOpen: boolean;
|
|
21
|
+
setIsOpen: (open: boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
|
24
|
+
null,
|
|
25
|
+
);
|
|
26
|
+
const useChainOfThought = () => {
|
|
27
|
+
const context = useContext(ChainOfThoughtContext);
|
|
28
|
+
if (!context) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"ChainOfThought components must be used within ChainOfThought",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return context;
|
|
34
|
+
};
|
|
35
|
+
export type ChainOfThoughtProps = ComponentProps<"div"> & {
|
|
36
|
+
open?: boolean;
|
|
37
|
+
defaultOpen?: boolean;
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
};
|
|
40
|
+
export function ChainOfThought({
|
|
41
|
+
className,
|
|
42
|
+
open,
|
|
43
|
+
defaultOpen = false,
|
|
44
|
+
onOpenChange,
|
|
45
|
+
children,
|
|
46
|
+
...props
|
|
47
|
+
}: ChainOfThoughtProps) {
|
|
48
|
+
const [isOpen, setIsOpen] = useControllableState({
|
|
49
|
+
defaultProp: defaultOpen,
|
|
50
|
+
onChange: onOpenChange,
|
|
51
|
+
prop: open,
|
|
52
|
+
});
|
|
53
|
+
const chainOfThoughtContext = { isOpen, setIsOpen };
|
|
54
|
+
return (
|
|
55
|
+
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
|
56
|
+
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
|
|
57
|
+
{children}
|
|
58
|
+
</div>
|
|
59
|
+
</ChainOfThoughtContext.Provider>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
export type ChainOfThoughtHeaderProps = ComponentProps<
|
|
63
|
+
typeof CollapsibleTrigger
|
|
64
|
+
> & {
|
|
65
|
+
icon?: ReactNode;
|
|
66
|
+
};
|
|
67
|
+
export function ChainOfThoughtHeader({
|
|
68
|
+
className,
|
|
69
|
+
children,
|
|
70
|
+
icon,
|
|
71
|
+
...props
|
|
72
|
+
}: ChainOfThoughtHeaderProps) {
|
|
73
|
+
const { isOpen, setIsOpen } = useChainOfThought();
|
|
74
|
+
return (
|
|
75
|
+
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
|
76
|
+
<CollapsibleTrigger
|
|
77
|
+
className={cn(
|
|
78
|
+
"flex w-full items-center gap-2 text-muted text-sm transition-[color,transform] duration-150 ease-out active:scale-[0.99] hover:text-foreground",
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
{...props}
|
|
82
|
+
>
|
|
83
|
+
{icon ?? <IconBrain className="size-4" />}
|
|
84
|
+
<span className="flex-1 text-left">
|
|
85
|
+
{children ?? "Chain of Thought"}
|
|
86
|
+
</span>
|
|
87
|
+
<IconChevronDown
|
|
88
|
+
className={cn(
|
|
89
|
+
"size-4 transition-transform duration-150 ease-out",
|
|
90
|
+
isOpen ? "rotate-180" : "rotate-0",
|
|
91
|
+
)}
|
|
92
|
+
/>
|
|
93
|
+
</CollapsibleTrigger>
|
|
94
|
+
</Collapsible>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
|
98
|
+
icon?: TablerIcon;
|
|
99
|
+
label: ReactNode;
|
|
100
|
+
description?: ReactNode;
|
|
101
|
+
status?: "complete" | "active" | "pending";
|
|
102
|
+
};
|
|
103
|
+
const stepStatusStyles = {
|
|
104
|
+
active: "text-foreground",
|
|
105
|
+
complete: "text-muted",
|
|
106
|
+
pending: "text-muted/50",
|
|
107
|
+
};
|
|
108
|
+
export function ChainOfThoughtStep({
|
|
109
|
+
className,
|
|
110
|
+
icon: Icon = IconPointFilled,
|
|
111
|
+
label,
|
|
112
|
+
description,
|
|
113
|
+
status = "complete",
|
|
114
|
+
children,
|
|
115
|
+
...props
|
|
116
|
+
}: ChainOfThoughtStepProps) {
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
className={cn(
|
|
120
|
+
"flex gap-2 text-sm",
|
|
121
|
+
stepStatusStyles[status],
|
|
122
|
+
"fade-in-0 slide-in-from-top-2 animate-in duration-200 ease-[cubic-bezier(0.32,0.72,0,1)]",
|
|
123
|
+
className,
|
|
124
|
+
)}
|
|
125
|
+
{...props}
|
|
126
|
+
>
|
|
127
|
+
<div className="relative mt-0.5">
|
|
128
|
+
<Icon className="size-4" />
|
|
129
|
+
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-separator" />
|
|
130
|
+
</div>
|
|
131
|
+
<div className="flex-1 space-y-2 overflow-hidden">
|
|
132
|
+
<div>{label}</div>
|
|
133
|
+
{description && <div className="text-muted text-xs">{description}</div>}
|
|
134
|
+
{children}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
|
140
|
+
export function ChainOfThoughtSearchResults({
|
|
141
|
+
className,
|
|
142
|
+
...props
|
|
143
|
+
}: ChainOfThoughtSearchResultsProps) {
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
className={cn("flex flex-wrap items-center gap-2", className)}
|
|
147
|
+
{...props}
|
|
148
|
+
/>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
|
152
|
+
export function ChainOfThoughtSearchResult({
|
|
153
|
+
className,
|
|
154
|
+
children,
|
|
155
|
+
...props
|
|
156
|
+
}: ChainOfThoughtSearchResultProps) {
|
|
157
|
+
return (
|
|
158
|
+
<Badge
|
|
159
|
+
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
|
|
160
|
+
variant="secondary"
|
|
161
|
+
{...props}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</Badge>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
export type ChainOfThoughtContentProps = ComponentProps<
|
|
168
|
+
typeof CollapsibleContent
|
|
169
|
+
>;
|
|
170
|
+
export function ChainOfThoughtContent({
|
|
171
|
+
className,
|
|
172
|
+
children,
|
|
173
|
+
...props
|
|
174
|
+
}: ChainOfThoughtContentProps) {
|
|
175
|
+
const { isOpen } = useChainOfThought();
|
|
176
|
+
return (
|
|
177
|
+
<Collapsible open={isOpen}>
|
|
178
|
+
<CollapsibleContent
|
|
179
|
+
className={cn(
|
|
180
|
+
"mt-2 space-y-3 text-foreground outline-hidden",
|
|
181
|
+
className,
|
|
182
|
+
)}
|
|
183
|
+
{...props}
|
|
184
|
+
>
|
|
185
|
+
{children}
|
|
186
|
+
</CollapsibleContent>
|
|
187
|
+
</Collapsible>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
|
191
|
+
caption?: string;
|
|
192
|
+
};
|
|
193
|
+
export function ChainOfThoughtImage({
|
|
194
|
+
className,
|
|
195
|
+
children,
|
|
196
|
+
caption,
|
|
197
|
+
...props
|
|
198
|
+
}: ChainOfThoughtImageProps) {
|
|
199
|
+
return (
|
|
200
|
+
<div className={cn("mt-2 space-y-2", className)} {...props}>
|
|
201
|
+
<div
|
|
202
|
+
className={cn(
|
|
203
|
+
flatPanelSubtleClass,
|
|
204
|
+
"relative flex max-h-88 items-center justify-center overflow-hidden rounded-lg p-3",
|
|
205
|
+
)}
|
|
206
|
+
>
|
|
207
|
+
{children}
|
|
208
|
+
</div>
|
|
209
|
+
{caption && <p className="text-muted text-xs">{caption}</p>}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
|
3
|
+
import type {
|
|
4
|
+
BundledLanguage,
|
|
5
|
+
BundledTheme,
|
|
6
|
+
HighlighterGeneric,
|
|
7
|
+
ThemedToken,
|
|
8
|
+
} from "shiki";
|
|
9
|
+
import { Button } from "../ui/button";
|
|
10
|
+
import {
|
|
11
|
+
Select,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
SelectTrigger,
|
|
15
|
+
SelectValue,
|
|
16
|
+
} from "../ui/radix-select";
|
|
17
|
+
import { cn } from "../utils/cn";
|
|
18
|
+
import { flatPanelSubtleClass } from "../ui/surface-classes";
|
|
19
|
+
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
|
20
|
+
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
|
21
|
+
import { bundledLanguages, createHighlighter } from "shiki";
|
|
22
|
+
const isBundledLanguage = (lang: string): lang is BundledLanguage =>
|
|
23
|
+
lang in bundledLanguages;
|
|
24
|
+
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
|
25
|
+
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
|
|
26
|
+
// eslint-disable-next-line no-bitwise -- shiki bitflag check
|
|
27
|
+
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
|
|
28
|
+
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
|
|
29
|
+
// eslint-disable-next-line no-bitwise -- shiki bitflag check
|
|
30
|
+
// oxlint-disable-next-line eslint(no-bitwise)
|
|
31
|
+
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
|
|
32
|
+
const isUnderline = (fontStyle: number | undefined) =>
|
|
33
|
+
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
|
|
34
|
+
// oxlint-disable-next-line eslint(no-bitwise)
|
|
35
|
+
fontStyle && fontStyle & 4;
|
|
36
|
+
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
|
37
|
+
interface KeyedToken {
|
|
38
|
+
token: ThemedToken;
|
|
39
|
+
key: string;
|
|
40
|
+
}
|
|
41
|
+
interface KeyedLine {
|
|
42
|
+
tokens: KeyedToken[];
|
|
43
|
+
key: string;
|
|
44
|
+
}
|
|
45
|
+
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
|
46
|
+
lines.map((line, lineIdx) => ({
|
|
47
|
+
key: `line-${lineIdx}`,
|
|
48
|
+
tokens: line.map((token, tokenIdx) => ({
|
|
49
|
+
key: `line-${lineIdx}-${tokenIdx}`,
|
|
50
|
+
token,
|
|
51
|
+
})),
|
|
52
|
+
}));
|
|
53
|
+
// Token rendering component
|
|
54
|
+
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
|
55
|
+
<span
|
|
56
|
+
className="dark:bg-(--shiki-dark-bg)! dark:text-(--shiki-dark)!"
|
|
57
|
+
style={
|
|
58
|
+
{
|
|
59
|
+
backgroundColor: token.bgColor,
|
|
60
|
+
color: token.color,
|
|
61
|
+
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
|
62
|
+
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
|
63
|
+
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
|
64
|
+
...token.htmlStyle,
|
|
65
|
+
} as CSSProperties
|
|
66
|
+
}
|
|
67
|
+
>
|
|
68
|
+
{token.content}
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
// Line rendering component
|
|
72
|
+
const LineSpan = ({
|
|
73
|
+
keyedLine,
|
|
74
|
+
showLineNumbers,
|
|
75
|
+
}: {
|
|
76
|
+
keyedLine: KeyedLine;
|
|
77
|
+
showLineNumbers: boolean;
|
|
78
|
+
}) => (
|
|
79
|
+
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
|
80
|
+
{keyedLine.tokens.length === 0
|
|
81
|
+
? "\n"
|
|
82
|
+
: keyedLine.tokens.map(({ token, key }) => (
|
|
83
|
+
<TokenSpan key={key} token={token} />
|
|
84
|
+
))}
|
|
85
|
+
</span>
|
|
86
|
+
);
|
|
87
|
+
// Types
|
|
88
|
+
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
|
89
|
+
code: string;
|
|
90
|
+
language: string;
|
|
91
|
+
showLineNumbers?: boolean;
|
|
92
|
+
};
|
|
93
|
+
interface TokenizedCode {
|
|
94
|
+
tokens: ThemedToken[][];
|
|
95
|
+
fg: string;
|
|
96
|
+
bg: string;
|
|
97
|
+
}
|
|
98
|
+
interface CodeBlockContextType {
|
|
99
|
+
code: string;
|
|
100
|
+
}
|
|
101
|
+
// Context
|
|
102
|
+
const CodeBlockContext = createContext<CodeBlockContextType>({
|
|
103
|
+
code: "",
|
|
104
|
+
});
|
|
105
|
+
// Highlighter cache (singleton per language)
|
|
106
|
+
const highlighterCache = new Map<
|
|
107
|
+
string,
|
|
108
|
+
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
|
109
|
+
>();
|
|
110
|
+
// Token cache
|
|
111
|
+
const tokensCache = new Map<string, TokenizedCode>();
|
|
112
|
+
// Subscribers for async token updates
|
|
113
|
+
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
|
114
|
+
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
|
115
|
+
const start = code.slice(0, 100);
|
|
116
|
+
const end = code.length > 100 ? code.slice(-100) : "";
|
|
117
|
+
return `${language}:${code.length}:${start}:${end}`;
|
|
118
|
+
};
|
|
119
|
+
const getHighlighter = (
|
|
120
|
+
language: BundledLanguage,
|
|
121
|
+
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
|
122
|
+
const cached = highlighterCache.get(language);
|
|
123
|
+
if (cached) {
|
|
124
|
+
return cached;
|
|
125
|
+
}
|
|
126
|
+
const highlighterPromise = createHighlighter({
|
|
127
|
+
langs: [language],
|
|
128
|
+
themes: ["github-light", "github-dark"],
|
|
129
|
+
});
|
|
130
|
+
highlighterCache.set(language, highlighterPromise);
|
|
131
|
+
return highlighterPromise;
|
|
132
|
+
};
|
|
133
|
+
// Create raw tokens for immediate display while highlighting loads
|
|
134
|
+
const createRawTokens = (code: string): TokenizedCode => ({
|
|
135
|
+
bg: "transparent",
|
|
136
|
+
fg: "inherit",
|
|
137
|
+
tokens: code.split("\n").map((line) =>
|
|
138
|
+
line === ""
|
|
139
|
+
? []
|
|
140
|
+
: [
|
|
141
|
+
{
|
|
142
|
+
color: "inherit",
|
|
143
|
+
content: line,
|
|
144
|
+
} as ThemedToken,
|
|
145
|
+
],
|
|
146
|
+
),
|
|
147
|
+
});
|
|
148
|
+
// Synchronous highlight with callback for async results
|
|
149
|
+
export const highlightCode = (
|
|
150
|
+
code: string,
|
|
151
|
+
language: string,
|
|
152
|
+
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
|
153
|
+
callback?: (result: TokenizedCode) => void,
|
|
154
|
+
): TokenizedCode | null => {
|
|
155
|
+
if (!isBundledLanguage(language)) return null;
|
|
156
|
+
const tokensCacheKey = getTokensCacheKey(code, language);
|
|
157
|
+
// Return cached result if available
|
|
158
|
+
const cached = tokensCache.get(tokensCacheKey);
|
|
159
|
+
if (cached) {
|
|
160
|
+
return cached;
|
|
161
|
+
}
|
|
162
|
+
// Subscribe callback if provided
|
|
163
|
+
if (callback) {
|
|
164
|
+
if (!subscribers.has(tokensCacheKey)) {
|
|
165
|
+
subscribers.set(tokensCacheKey, new Set());
|
|
166
|
+
}
|
|
167
|
+
subscribers.get(tokensCacheKey)?.add(callback);
|
|
168
|
+
}
|
|
169
|
+
// Start highlighting in background - fire-and-forget async pattern
|
|
170
|
+
getHighlighter(language)
|
|
171
|
+
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
|
172
|
+
.then((highlighter) => {
|
|
173
|
+
const availableLangs = highlighter.getLoadedLanguages();
|
|
174
|
+
const langToUse = availableLangs.includes(language) ? language : "text";
|
|
175
|
+
const result = highlighter.codeToTokens(code, {
|
|
176
|
+
lang: langToUse,
|
|
177
|
+
themes: {
|
|
178
|
+
dark: "github-dark",
|
|
179
|
+
light: "github-light",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
const tokenized: TokenizedCode = {
|
|
183
|
+
bg: result.bg ?? "transparent",
|
|
184
|
+
fg: result.fg ?? "inherit",
|
|
185
|
+
tokens: result.tokens,
|
|
186
|
+
};
|
|
187
|
+
// Cache the result
|
|
188
|
+
tokensCache.set(tokensCacheKey, tokenized);
|
|
189
|
+
// Notify all subscribers
|
|
190
|
+
const subs = subscribers.get(tokensCacheKey);
|
|
191
|
+
if (subs) {
|
|
192
|
+
for (const sub of subs) {
|
|
193
|
+
sub(tokenized);
|
|
194
|
+
}
|
|
195
|
+
subscribers.delete(tokensCacheKey);
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
|
|
199
|
+
.catch((error) => {
|
|
200
|
+
console.error("Failed to highlight code:", error);
|
|
201
|
+
subscribers.delete(tokensCacheKey);
|
|
202
|
+
});
|
|
203
|
+
return null;
|
|
204
|
+
};
|
|
205
|
+
// Line number styles using CSS counters
|
|
206
|
+
const LINE_NUMBER_CLASSES = cn(
|
|
207
|
+
"block",
|
|
208
|
+
"before:content-[counter(line)]",
|
|
209
|
+
"before:inline-block",
|
|
210
|
+
"before:[counter-increment:line]",
|
|
211
|
+
"before:w-8",
|
|
212
|
+
"before:mr-4",
|
|
213
|
+
"before:text-right",
|
|
214
|
+
"before:text-muted/50",
|
|
215
|
+
"before:font-mono",
|
|
216
|
+
"before:select-none",
|
|
217
|
+
);
|
|
218
|
+
function CodeBlockBody({
|
|
219
|
+
tokenized,
|
|
220
|
+
showLineNumbers,
|
|
221
|
+
className,
|
|
222
|
+
}: {
|
|
223
|
+
tokenized: TokenizedCode;
|
|
224
|
+
showLineNumbers: boolean;
|
|
225
|
+
className?: string;
|
|
226
|
+
}) {
|
|
227
|
+
const preStyle = {
|
|
228
|
+
backgroundColor: tokenized.bg,
|
|
229
|
+
color: tokenized.fg,
|
|
230
|
+
};
|
|
231
|
+
const keyedLines = addKeysToTokens(tokenized.tokens);
|
|
232
|
+
return (
|
|
233
|
+
<pre
|
|
234
|
+
className={cn(
|
|
235
|
+
"dark:bg-(--shiki-dark-bg)! dark:text-(--shiki-dark)! m-0 p-4 text-sm",
|
|
236
|
+
className,
|
|
237
|
+
)}
|
|
238
|
+
style={preStyle}
|
|
239
|
+
>
|
|
240
|
+
<code
|
|
241
|
+
className={cn(
|
|
242
|
+
"font-mono text-sm",
|
|
243
|
+
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]",
|
|
244
|
+
)}
|
|
245
|
+
>
|
|
246
|
+
{keyedLines.map((keyedLine) => (
|
|
247
|
+
<LineSpan
|
|
248
|
+
key={keyedLine.key}
|
|
249
|
+
keyedLine={keyedLine}
|
|
250
|
+
showLineNumbers={showLineNumbers}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</code>
|
|
254
|
+
</pre>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
export const CodeBlockContainer = ({
|
|
258
|
+
className,
|
|
259
|
+
language,
|
|
260
|
+
style,
|
|
261
|
+
...props
|
|
262
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
263
|
+
language: string;
|
|
264
|
+
}) => (
|
|
265
|
+
<div
|
|
266
|
+
className={cn(
|
|
267
|
+
"relative w-full overflow-hidden rounded-lg bg-surface-secondary/60 text-foreground",
|
|
268
|
+
className,
|
|
269
|
+
)}
|
|
270
|
+
data-language={language}
|
|
271
|
+
style={{
|
|
272
|
+
containIntrinsicSize: "auto 200px",
|
|
273
|
+
contentVisibility: "auto",
|
|
274
|
+
...style,
|
|
275
|
+
}}
|
|
276
|
+
{...props}
|
|
277
|
+
/>
|
|
278
|
+
);
|
|
279
|
+
export const CodeBlockHeader = ({
|
|
280
|
+
children,
|
|
281
|
+
className,
|
|
282
|
+
...props
|
|
283
|
+
}: HTMLAttributes<HTMLDivElement>) => (
|
|
284
|
+
<div
|
|
285
|
+
className={cn(
|
|
286
|
+
cn(
|
|
287
|
+
flatPanelSubtleClass,
|
|
288
|
+
"flex items-center justify-between px-3 py-2 text-muted text-xs",
|
|
289
|
+
),
|
|
290
|
+
className,
|
|
291
|
+
)}
|
|
292
|
+
{...props}
|
|
293
|
+
>
|
|
294
|
+
{children}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
export const CodeBlockTitle = ({
|
|
298
|
+
children,
|
|
299
|
+
className,
|
|
300
|
+
...props
|
|
301
|
+
}: HTMLAttributes<HTMLDivElement>) => (
|
|
302
|
+
<div className={cn("flex items-center gap-2", className)} {...props}>
|
|
303
|
+
{children}
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
export const CodeBlockFilename = ({
|
|
307
|
+
children,
|
|
308
|
+
className,
|
|
309
|
+
...props
|
|
310
|
+
}: HTMLAttributes<HTMLSpanElement>) => (
|
|
311
|
+
<span className={cn("font-mono", className)} {...props}>
|
|
312
|
+
{children}
|
|
313
|
+
</span>
|
|
314
|
+
);
|
|
315
|
+
export const CodeBlockActions = ({
|
|
316
|
+
children,
|
|
317
|
+
className,
|
|
318
|
+
...props
|
|
319
|
+
}: HTMLAttributes<HTMLDivElement>) => (
|
|
320
|
+
<div
|
|
321
|
+
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
|
|
322
|
+
{...props}
|
|
323
|
+
>
|
|
324
|
+
{children}
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
export const CodeBlockContent = ({
|
|
328
|
+
code,
|
|
329
|
+
language,
|
|
330
|
+
showLineNumbers = false,
|
|
331
|
+
}: {
|
|
332
|
+
code: string;
|
|
333
|
+
language: string;
|
|
334
|
+
showLineNumbers?: boolean;
|
|
335
|
+
}) => {
|
|
336
|
+
// Memoized raw tokens for immediate display
|
|
337
|
+
const rawTokens = createRawTokens(code);
|
|
338
|
+
// Try to get cached result synchronously, otherwise use raw tokens
|
|
339
|
+
const [tokenized, setTokenized] = useState<TokenizedCode>(
|
|
340
|
+
() => highlightCode(code, language) ?? rawTokens,
|
|
341
|
+
);
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
let cancelled = false;
|
|
344
|
+
// Reset to raw tokens when code changes (shows current code, not stale tokens)
|
|
345
|
+
setTokenized(highlightCode(code, language) ?? rawTokens);
|
|
346
|
+
// Subscribe to async highlighting result
|
|
347
|
+
highlightCode(code, language, (result) => {
|
|
348
|
+
if (!cancelled) {
|
|
349
|
+
setTokenized(result);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
return () => {
|
|
353
|
+
cancelled = true;
|
|
354
|
+
};
|
|
355
|
+
}, [code, language, rawTokens]);
|
|
356
|
+
return (
|
|
357
|
+
<div className="relative overflow-auto">
|
|
358
|
+
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
};
|
|
362
|
+
export const CodeBlock = ({
|
|
363
|
+
code,
|
|
364
|
+
language,
|
|
365
|
+
showLineNumbers = false,
|
|
366
|
+
className,
|
|
367
|
+
children,
|
|
368
|
+
...props
|
|
369
|
+
}: CodeBlockProps) => {
|
|
370
|
+
const contextValue = { code };
|
|
371
|
+
return (
|
|
372
|
+
<CodeBlockContext.Provider value={contextValue}>
|
|
373
|
+
<CodeBlockContainer className={className} language={language} {...props}>
|
|
374
|
+
{children}
|
|
375
|
+
<CodeBlockContent
|
|
376
|
+
code={code}
|
|
377
|
+
language={language}
|
|
378
|
+
showLineNumbers={showLineNumbers}
|
|
379
|
+
/>
|
|
380
|
+
</CodeBlockContainer>
|
|
381
|
+
</CodeBlockContext.Provider>
|
|
382
|
+
);
|
|
383
|
+
};
|
|
384
|
+
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
|
385
|
+
onCopy?: () => void;
|
|
386
|
+
onError?: (error: Error) => void;
|
|
387
|
+
timeout?: number;
|
|
388
|
+
};
|
|
389
|
+
export const CodeBlockCopyButton = ({
|
|
390
|
+
onCopy,
|
|
391
|
+
onError,
|
|
392
|
+
timeout = 2000,
|
|
393
|
+
children,
|
|
394
|
+
className,
|
|
395
|
+
...props
|
|
396
|
+
}: CodeBlockCopyButtonProps) => {
|
|
397
|
+
const [isCopied, setIsCopied] = useState(false);
|
|
398
|
+
const timeoutRef = useRef<number>(0);
|
|
399
|
+
const { code } = useContext(CodeBlockContext);
|
|
400
|
+
const copyToClipboard = async () => {
|
|
401
|
+
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
|
402
|
+
onError?.(new Error("Clipboard API not available"));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
if (!isCopied) {
|
|
407
|
+
await navigator.clipboard.writeText(code);
|
|
408
|
+
setIsCopied(true);
|
|
409
|
+
onCopy?.();
|
|
410
|
+
timeoutRef.current = window.setTimeout(
|
|
411
|
+
() => setIsCopied(false),
|
|
412
|
+
timeout,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
onError?.(error as Error);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
useEffect(
|
|
420
|
+
() => () => {
|
|
421
|
+
window.clearTimeout(timeoutRef.current);
|
|
422
|
+
},
|
|
423
|
+
[],
|
|
424
|
+
);
|
|
425
|
+
const Icon = isCopied ? IconCheck : IconCopy;
|
|
426
|
+
return (
|
|
427
|
+
<Button
|
|
428
|
+
className={cn("shrink-0", className)}
|
|
429
|
+
onClick={copyToClipboard}
|
|
430
|
+
size="icon"
|
|
431
|
+
variant="ghost"
|
|
432
|
+
{...props}
|
|
433
|
+
>
|
|
434
|
+
{children ?? <Icon size={14} />}
|
|
435
|
+
</Button>
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
|
|
439
|
+
export const CodeBlockLanguageSelector = (
|
|
440
|
+
props: CodeBlockLanguageSelectorProps,
|
|
441
|
+
) => <Select {...props} />;
|
|
442
|
+
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
|
|
443
|
+
typeof SelectTrigger
|
|
444
|
+
>;
|
|
445
|
+
export const CodeBlockLanguageSelectorTrigger = ({
|
|
446
|
+
className,
|
|
447
|
+
...props
|
|
448
|
+
}: CodeBlockLanguageSelectorTriggerProps) => (
|
|
449
|
+
<SelectTrigger
|
|
450
|
+
className={cn(
|
|
451
|
+
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
|
|
452
|
+
className,
|
|
453
|
+
)}
|
|
454
|
+
size="sm"
|
|
455
|
+
{...props}
|
|
456
|
+
/>
|
|
457
|
+
);
|
|
458
|
+
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
|
|
459
|
+
typeof SelectValue
|
|
460
|
+
>;
|
|
461
|
+
export const CodeBlockLanguageSelectorValue = (
|
|
462
|
+
props: CodeBlockLanguageSelectorValueProps,
|
|
463
|
+
) => <SelectValue {...props} />;
|
|
464
|
+
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
|
|
465
|
+
typeof SelectContent
|
|
466
|
+
>;
|
|
467
|
+
export const CodeBlockLanguageSelectorContent = ({
|
|
468
|
+
align = "end",
|
|
469
|
+
...props
|
|
470
|
+
}: CodeBlockLanguageSelectorContentProps) => (
|
|
471
|
+
<SelectContent align={align} {...props} />
|
|
472
|
+
);
|
|
473
|
+
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
|
|
474
|
+
typeof SelectItem
|
|
475
|
+
>;
|
|
476
|
+
export const CodeBlockLanguageSelectorItem = (
|
|
477
|
+
props: CodeBlockLanguageSelectorItemProps,
|
|
478
|
+
) => <SelectItem {...props} />;
|