Skip to content

Commit 8eb818c

Browse files
committed
feat(docs): add copy button, install dependency, and heading components, and update component index
1 parent 76c5227 commit 8eb818c

File tree

7 files changed

+310
-1
lines changed

7 files changed

+310
-1
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client';
2+
3+
import { Check, Copy } from 'lucide-react';
4+
import { useEffect, useState } from 'react';
5+
import type { FC, MouseEvent } from 'react';
6+
7+
import { type ButtonIconProps } from '@/components/button';
8+
import { ButtonIcon } from '@/components/button';
9+
10+
interface CopyButtonProps extends ButtonIconProps {
11+
content?: string;
12+
getContent?: (event: MouseEvent<HTMLButtonElement>) => string;
13+
}
14+
15+
/* -------------------- CopyToClipboard -------------------- */
16+
const CopyButton: FC<CopyButtonProps> = props => {
17+
const { content, getContent, ...rest } = props;
18+
19+
const [isCopied, setIsCopied] = useState(false);
20+
21+
useEffect(() => {
22+
if (!isCopied) return () => {};
23+
const timer = setTimeout(() => setIsCopied(false), 2000);
24+
return () => clearTimeout(timer);
25+
}, [isCopied]);
26+
27+
const handleClick = async (event: MouseEvent<HTMLButtonElement>) => {
28+
const copyContent = content || getContent?.(event) || '';
29+
30+
try {
31+
await navigator.clipboard.writeText(copyContent);
32+
setIsCopied(true);
33+
} catch {
34+
console.error('Failed to copy!');
35+
}
36+
};
37+
38+
return (
39+
<ButtonIcon
40+
title="copy code"
41+
onClick={handleClick}
42+
{...rest}
43+
>
44+
{isCopied ? <Check size={16} /> : <Copy size={16} />}
45+
</ButtonIcon>
46+
);
47+
};
48+
49+
export default CopyButton;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
5+
import { Tabs } from '@/components/tabs';
6+
7+
import CopyButton from './CopyButton';
8+
9+
type InstallDependenciesProps = {
10+
pkg: string;
11+
};
12+
13+
const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const;
14+
15+
const InstallDependencies = (props: InstallDependenciesProps) => {
16+
const { pkg } = props;
17+
18+
const [activePackageManager, setActivePackageManager] = useState<string>(PACKAGE_MANAGERS[0]);
19+
20+
const items = PACKAGE_MANAGERS.map(manager => ({
21+
children: () => (
22+
<div className="h-10 flex-y-center justify-between gap-2 border rounded-md pl-3 pr-1.5">
23+
<code className="text-sm">
24+
$ {manager} add {pkg}
25+
</code>
26+
<CopyButton content={`${manager} add ${props.pkg}`} />
27+
</div>
28+
),
29+
label: manager,
30+
value: manager
31+
}));
32+
33+
return (
34+
<Tabs
35+
className="w-fit"
36+
defaultValue="npm"
37+
items={items}
38+
value={activePackageManager}
39+
onValueChange={setActivePackageManager}
40+
/>
41+
);
42+
};
43+
44+
export default InstallDependencies;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
'use client';
2+
3+
import cn from 'clsx';
4+
import { Check, Copy, WrapText } from 'lucide-react';
5+
import { type FC, type HTMLAttributes, type MouseEvent, type ReactNode, useEffect, useState } from 'react';
6+
import type { ButtonProps } from 'soybean-react-ui';
7+
import { Button } from 'soybean-react-ui';
8+
9+
/* -------------------- ToggleWordWrapButton -------------------- */
10+
function toggleWordWrap() {
11+
const htmlDataset = document.documentElement.dataset;
12+
if ('nextraWordWrap' in htmlDataset) delete htmlDataset.nextraWordWrap;
13+
else htmlDataset.nextraWordWrap = '';
14+
}
15+
16+
export const ToggleWordWrapButton: FC<{ children?: ReactNode }> = ({ children }) => (
17+
<Button
18+
className="flex items-center gap-1 rounded-md border border-border/50 bg-transparent hover:bg-muted"
19+
size="sm"
20+
title="切换自动换行"
21+
variant="outline"
22+
onClick={toggleWordWrap}
23+
>
24+
{children ?? <WrapText size={16} />}
25+
</Button>
26+
);
27+
28+
/* -------------------- Pre 组件 -------------------- */
29+
export interface PreProps extends HTMLAttributes<HTMLPreElement> {
30+
'data-copy'?: '';
31+
'data-filename'?: string;
32+
'data-language'?: string;
33+
'data-word-wrap'?: '';
34+
icon?: ReactNode;
35+
}
36+
37+
export const Pre: FC<PreProps> = rest => {
38+
const {
39+
children,
40+
className,
41+
'data-copy': copy,
42+
'data-filename': filename,
43+
'data-language': _lang,
44+
'data-word-wrap': hasWordWrap,
45+
icon,
46+
...props
47+
} = rest;
48+
49+
const copyButton = copy === '' && <CopyToClipboard />;
50+
51+
return (
52+
<div className="code-block relative my-6 w-full">
53+
{/* 文件名栏 */}
54+
{filename && (
55+
<div
56+
className={cn(
57+
'flex items-center justify-between gap-2 rounded-t-md border border-border/50 border-b-0',
58+
'bg-gray-100 dark:bg-neutral-900 px-4 py-2 text-xs text-gray-700 dark:text-gray-200'
59+
)}
60+
>
61+
<div className="flex items-center gap-2 min-w-0">
62+
{icon}
63+
<span className="truncate">{filename}</span>
64+
</div>
65+
{copyButton}
66+
</div>
67+
)}
68+
69+
{/* 代码区 */}
70+
<div
71+
className={cn(
72+
'relative group rounded-b-md border border-border/50 bg-white dark:bg-black',
73+
filename ? 'rounded-t-none' : 'rounded-md'
74+
)}
75+
>
76+
{/* hover 按钮区 */}
77+
<div
78+
className={cn(
79+
'absolute right-3 flex gap-2 transition-opacity',
80+
'opacity-0 group-hover:opacity-100 focus-within:opacity-100',
81+
filename ? 'top-3' : 'top-3'
82+
)}
83+
>
84+
{hasWordWrap === '' && <ToggleWordWrapButton />}
85+
{!filename && copyButton}
86+
</div>
87+
88+
<pre
89+
className={cn(
90+
'overflow-x-auto p-4 text-sm leading-relaxed subpixel-antialiased not-prose',
91+
'bg-transparent text-foreground dark:text-foreground/90',
92+
'font-mono',
93+
className
94+
)}
95+
{...props}
96+
>
97+
{children}
98+
</pre>
99+
</div>
100+
</div>
101+
);
102+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client';
2+
3+
import cn from 'clsx';
4+
import type { ComponentProps, FC } from 'react';
5+
6+
const createHeading = (Tag: `h${1 | 2 | 3 | 4 | 5 | 6}`): FC<ComponentProps<typeof Tag>> =>
7+
function Heading({ children, className, id, ...props }: ComponentProps<typeof Tag>) {
8+
const _class =
9+
className === 'sr-only'
10+
? 'sr-only'
11+
: cn(
12+
'group scroll-mt-20 tracking-tight font-semibold',
13+
{
14+
h1: cn('scroll-m-20 text-4xl font-extrabold tracking-tight text-balance'),
15+
h2: cn('scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0'),
16+
h3: cn('mt-10 mb-4 text-xl md:text-2xl', 'font-semibold leading-[1.4]', 'relative'),
17+
h4: cn('mt-8 mb-3 text-lg md:text-xl', 'font-semibold leading-[1.5]', 'text-foreground/90'),
18+
h5: cn('mt-6 mb-2 text-base md:text-lg', 'font-semibold leading-[1.6]', 'text-foreground/80'),
19+
h6: cn(
20+
'mt-6 mb-2 text-sm md:text-base',
21+
'font-semibold leading-[1.6]',
22+
'text-muted-foreground uppercase tracking-wider'
23+
)
24+
}[Tag],
25+
className
26+
);
27+
28+
return (
29+
<Tag
30+
className={_class}
31+
id={id}
32+
{...props}
33+
>
34+
{children}
35+
</Tag>
36+
);
37+
};
38+
39+
export const H1 = createHeading('h1');
40+
export const H2 = createHeading('h2');
41+
export const H3 = createHeading('h3');
42+
export const H4 = createHeading('h4');
43+
export const H5 = createHeading('h5');
44+
export const H6 = createHeading('h6');
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export { default as Code } from './Code';
2+
3+
export { default as CopyButton } from './CopyButton';
4+
5+
export { default as InstallDependencies } from './InstallDependencies';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// src/lib/shiki.ts
2+
import { type HighlighterCore, createHighlighterCore } from 'shiki';
3+
import { createOnigurumaEngine } from 'shiki';
4+
5+
let highlighterInstance: Promise<HighlighterCore> | null = null;
6+
let disposed = false;
7+
8+
/**
9+
* 懒加载 Shiki 高亮器(单例)
10+
*/
11+
async function getHighlighter(): Promise<HighlighterCore> {
12+
if (disposed) {
13+
highlighterInstance = null;
14+
disposed = false;
15+
}
16+
17+
if (!highlighterInstance) {
18+
highlighterInstance = (async () => {
19+
const highlighter = await createHighlighterCore({
20+
engine: createOnigurumaEngine(import('shiki/wasm')),
21+
langs: [
22+
() => import('shiki/langs/typescript.mjs'),
23+
() => import('shiki/langs/javascript.mjs'),
24+
() => import('shiki/langs/tsx.mjs'),
25+
() => import('shiki/langs/bash.mjs'),
26+
() => import('shiki/langs/html.mjs')
27+
],
28+
themes: [
29+
() => import('shiki/themes/github-dark-default.mjs'),
30+
() => import('shiki/themes/github-light-default.mjs')
31+
]
32+
});
33+
return highlighter;
34+
})();
35+
}
36+
37+
return highlighterInstance;
38+
}
39+
40+
/**
41+
* 对外暴露高亮方法
42+
*/
43+
export async function highlight(
44+
code: string,
45+
lang: string = 'tsx',
46+
theme: string = 'github-dark-default'
47+
): Promise<string> {
48+
const highlighter = await getHighlighter();
49+
return highlighter.codeToHtml(code, { defaultColor: false, lang, theme });
50+
}
51+
52+
/**
53+
* 手动释放资源
54+
*/
55+
export async function disposeHighlighter() {
56+
if (!highlighterInstance) return;
57+
try {
58+
const instance = await highlighterInstance;
59+
instance.dispose();
60+
} catch {}
61+
disposed = true;
62+
highlighterInstance = null;
63+
}

packages/ui/src/components/button/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import type { ComponentProps } from 'react';
2+
13
import type { BaseNodeProps, PrimitiveProps, ThemeColor, ThemeOrientation } from '@/types/other';
24

35
import type { IconProps } from '../icon';
46

57
import type { ButtonShadow, ButtonShape, ButtonVariant } from './button-variants';
68

7-
export interface ButtonProps extends PrimitiveProps, BaseNodeProps<React.ButtonHTMLAttributes<HTMLButtonElement>> {
9+
export interface ButtonProps extends PrimitiveProps, BaseNodeProps<ComponentProps<'button'>> {
810
color?: ThemeColor;
911
fitContent?: boolean;
1012
leading?: React.ReactNode;

0 commit comments

Comments
 (0)