ckan-devstaller/docs/components/codeblock.tsx

261 lines
6.4 KiB
TypeScript

'use client';
import { Check, Clipboard } from 'lucide-react';
import {
type ComponentProps,
createContext,
type HTMLAttributes,
type ReactNode,
type RefObject,
useContext,
useMemo,
useRef,
} from 'react';
import { cn } from '../lib/cn';
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';
import { buttonVariants } from './ui/button';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from './tabs.unstyled';
import { mergeRefs } from '../lib/merge-refs';
export interface CodeBlockProps extends ComponentProps<'figure'> {
/**
* Icon of code block
*
* When passed as a string, it assumes the value is the HTML of icon
*/
icon?: ReactNode;
/**
* Allow to copy code with copy button
*
* @defaultValue true
*/
allowCopy?: boolean;
/**
* Keep original background color generated by Shiki or Rehype Code
*
* @defaultValue false
*/
keepBackground?: boolean;
viewportProps?: HTMLAttributes<HTMLElement>;
/**
* show line numbers
*/
'data-line-numbers'?: boolean;
/**
* @defaultValue 1
*/
'data-line-numbers-start'?: number;
Actions?: (props: { className?: string; children?: ReactNode }) => ReactNode;
}
const TabsContext = createContext<{
containerRef: RefObject<HTMLDivElement | null>;
nested: boolean;
} | null>(null);
export function Pre(props: ComponentProps<'pre'>) {
return (
<pre
{...props}
className={cn('min-w-full w-max *:flex *:flex-col', props.className)}
>
{props.children}
</pre>
);
}
export function CodeBlock({
ref,
title,
allowCopy = true,
keepBackground = false,
icon,
viewportProps = {},
children,
Actions = (props) => (
<div {...props} className={cn('empty:hidden', props.className)} />
),
...props
}: CodeBlockProps) {
const inTab = useContext(TabsContext) !== null;
const areaRef = useRef<HTMLDivElement>(null);
return (
<figure
ref={ref}
dir="ltr"
{...props}
className={cn(
inTab
? 'bg-fd-secondary -mx-px -mb-px last:rounded-b-xl'
: 'my-4 bg-fd-card rounded-xl',
keepBackground && 'bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)',
'shiki relative border shadow-sm outline-none not-prose overflow-hidden text-sm',
props.className,
)}
>
{title ? (
<div className="flex text-fd-muted-foreground items-center gap-2 h-9.5 border-b px-4">
{typeof icon === 'string' ? (
<div
className="[&_svg]:size-3.5"
dangerouslySetInnerHTML={{
__html: icon,
}}
/>
) : (
icon
)}
<figcaption className="flex-1 truncate">{title}</figcaption>
{Actions({
className: '-me-2',
children: allowCopy && <CopyButton containerRef={areaRef} />,
})}
</div>
) : (
Actions({
className:
'absolute top-2 right-2 z-2 backdrop-blur-lg rounded-lg text-fd-muted-foreground',
children: allowCopy && <CopyButton containerRef={areaRef} />,
})
)}
<div
ref={areaRef}
{...viewportProps}
className={cn(
'text-[13px] py-3.5 overflow-auto max-h-[600px] fd-scroll-container',
viewportProps.className,
)}
style={
{
// space for toolbar
'--padding-right': !title ? 'calc(var(--spacing) * 8)' : undefined,
counterSet: props['data-line-numbers']
? `line ${Number(props['data-line-numbers-start'] ?? 1) - 1}`
: undefined,
...viewportProps.style,
} as object
}
>
{children}
</div>
</figure>
);
}
function CopyButton({
className,
containerRef,
...props
}: ComponentProps<'button'> & {
containerRef: RefObject<HTMLElement | null>;
}) {
const [checked, onClick] = useCopyButton(() => {
const pre = containerRef.current?.getElementsByTagName('pre').item(0);
if (!pre) return;
const clone = pre.cloneNode(true) as HTMLElement;
clone.querySelectorAll('.nd-copy-ignore').forEach((node) => {
node.replaceWith('\n');
});
void navigator.clipboard.writeText(clone.textContent ?? '');
});
return (
<button
type="button"
data-checked={checked || undefined}
className={cn(
buttonVariants({
className:
'hover:text-fd-accent-foreground data-[checked]:text-fd-accent-foreground',
size: 'icon-xs',
}),
className,
)}
aria-label={checked ? 'Copied Text' : 'Copy Text'}
onClick={onClick}
{...props}
>
{checked ? <Check /> : <Clipboard />}
</button>
);
}
export function CodeBlockTabs({ ref, ...props }: ComponentProps<typeof Tabs>) {
const containerRef = useRef<HTMLDivElement>(null);
const nested = useContext(TabsContext) !== null;
return (
<Tabs
ref={mergeRefs(containerRef, ref)}
{...props}
className={cn(
'bg-fd-card rounded-xl border',
!nested && 'my-4',
props.className,
)}
>
<TabsContext.Provider
value={useMemo(
() => ({
containerRef,
nested,
}),
[nested],
)}
>
{props.children}
</TabsContext.Provider>
</Tabs>
);
}
export function CodeBlockTabsList(props: ComponentProps<typeof TabsList>) {
return (
<TabsList
{...props}
className={cn(
'flex flex-row px-2 overflow-x-auto text-fd-muted-foreground',
props.className,
)}
>
{props.children}
</TabsList>
);
}
export function CodeBlockTabsTrigger({
children,
...props
}: ComponentProps<typeof TabsTrigger>) {
return (
<TabsTrigger
{...props}
className={cn(
'relative group inline-flex text-sm font-medium text-nowrap items-center transition-colors gap-2 px-2 py-1.5 hover:text-fd-accent-foreground data-[state=active]:text-fd-primary [&_svg]:size-3.5',
props.className,
)}
>
<div className="absolute inset-x-2 bottom-0 h-px group-data-[state=active]:bg-fd-primary" />
{children}
</TabsTrigger>
);
}
// TODO: currently Vite RSC plugin has problem with `asChild` due to children is automatically wrapped in <Fragment />, maybe revisit this in future
export function CodeBlockTab(props: ComponentProps<typeof TabsContent>) {
return <TabsContent {...props} />;
}