mirror of
https://github.com/dathere/ckan-devstaller.git
synced 2025-11-09 13:39:49 +00:00
261 lines
6.4 KiB
TypeScript
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} />;
|
|
}
|