Files
triggerdotdev--trigger.dev/apps/webapp/app/components/code/CodeBlock.tsx
T

388 lines
11 KiB
TypeScript

import { Clipboard, ClipboardCheck } from "lucide-react";
import type { Language, PrismTheme } from "prism-react-renderer";
import { Highlight, Prism } from "prism-react-renderer";
import { forwardRef, useCallback, useState } from "react";
import { cn } from "~/utils/cn";
import { Paragraph } from "../primitives/Paragraph";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
async function setup() {
(typeof global !== "undefined" ? global : window).Prism = Prism;
//@ts-ignore
await import("prismjs/components/prism-json");
//@ts-ignore
await import("prismjs/components/prism-typescript");
}
setup();
type CodeBlockProps = {
/** Code which will be highlighted */
code: string;
/** Programming language that should be highlighted */
language?: Language;
/** Show copy to clipboard button */
showCopyButton?: boolean;
/** Display line numbers */
showLineNumbers?: boolean;
/** Highlight line at given line number with color from theme.colors */
highlightedRanges?: [number, number][];
/** Add/override classes on the overall element */
className?: string;
/** Add/override code theme */
theme?: PrismTheme;
/** Max lines */
maxLines?: number;
/** Whether to show the chrome, if you provide a string it will be used as the title, */
showChrome?: boolean;
/** filename */
fileName?: string;
/** title text for the Title row */
rowTitle?: string;
};
const dimAmount = 0.5;
const extraLinesWhenClipping = 0.35;
const defaultTheme: PrismTheme = {
plain: {
color: "#9CDCFE",
backgroundColor: "rgba(0, 0, 0, 0)",
},
styles: [
{
types: ["prolog"],
style: {
color: "rgb(0, 0, 128)",
},
},
{
types: ["comment"],
style: {
color: "rgb(106, 153, 85)",
},
},
{
types: ["builtin", "changed", "keyword", "interpolation-punctuation"],
style: {
color: "rgb(86, 156, 214)",
},
},
{
types: ["number", "inserted"],
style: {
color: "rgb(181, 206, 168)",
},
},
{
types: ["constant"],
style: {
color: "rgb(100, 102, 149)",
},
},
{
types: ["attr-name", "variable"],
style: {
color: "rgb(156, 220, 254)",
},
},
{
types: ["deleted", "string", "attr-value", "template-punctuation"],
style: {
color: "rgb(206, 145, 120)",
},
},
{
types: ["selector"],
style: {
color: "rgb(215, 186, 125)",
},
},
{
// Fix tag color
types: ["tag"],
style: {
color: "rgb(78, 201, 176)",
},
},
{
// Fix tag color for HTML
types: ["tag"],
languages: ["markup"],
style: {
color: "rgb(86, 156, 214)",
},
},
{
types: ["punctuation", "operator"],
style: {
color: "rgb(212, 212, 212)",
},
},
{
// Fix punctuation color for HTML
types: ["punctuation"],
languages: ["markup"],
style: {
color: "#808080",
},
},
{
types: ["function"],
style: {
color: "rgb(220, 220, 170)",
},
},
{
types: ["class-name"],
style: {
color: "rgb(78, 201, 176)",
},
},
{
types: ["char"],
style: {
color: "rgb(209, 105, 105)",
},
},
],
};
export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
(
{
showCopyButton = true,
showLineNumbers = true,
highlightedRanges,
code,
className,
language = "typescript",
theme = defaultTheme,
maxLines,
showChrome = false,
fileName,
rowTitle,
...props
}: CodeBlockProps,
ref
) => {
const [mouseOver, setMouseOver] = useState(false);
const [copied, setCopied] = useState(false);
const onCopied = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[code]
);
code = code.trim();
const lineCount = code.split("\n").length;
const maxLineWidth = lineCount.toString().length;
let maxHeight: string | undefined = undefined;
if (maxLines && lineCount > maxLines) {
maxHeight = `calc(${(maxLines + extraLinesWhenClipping) * 0.75 * 1.625}rem + 1.5rem )`;
}
const highlightLines = highlightedRanges?.flatMap(([start, end]) =>
Array.from({ length: end - start + 1 }, (_, i) => start + i)
);
// if there are more than 1000 lines, don't highlight
const shouldHighlight = lineCount <= 1000;
return (
<div
className={cn("relative overflow-hidden rounded-md border border-grid-bright", className)}
style={{
backgroundColor: theme.plain.backgroundColor,
}}
ref={ref}
{...props}
translate="no"
>
{showChrome && <Chrome title={fileName} />}
{rowTitle && <TitleRow title={rowTitle} />}
{showCopyButton && (
<TooltipProvider>
<Tooltip open={copied || mouseOver}>
<TooltipTrigger
onClick={onCopied}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
className={cn(
"absolute right-3 z-50 transition-colors duration-100 hover:cursor-pointer",
showChrome ? "top-10" : "top-3",
copied ? "text-emerald-500" : "text-charcoal-500 hover:text-charcoal-300"
)}
>
{copied ? (
<ClipboardCheck className="h-5 w-5" />
) : (
<Clipboard className="h-5 w-5" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{copied ? "Copied" : "Copy"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{shouldHighlight ? (
<Highlight theme={theme} code={code} language={language}>
{({
className: inheritedClassName,
style: inheritedStyle,
tokens,
getLineProps,
getTokenProps,
}) => (
<div
dir="ltr"
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
style={{
maxHeight,
}}
>
<pre
className={cn(
"relative mr-2 font-mono text-xs leading-relaxed",
inheritedClassName
)}
style={inheritedStyle}
dir="ltr"
>
{tokens
.map((line, index) => {
if (
index === tokens.length - 1 &&
line.length === 1 &&
line[0].content === "\n"
) {
return null;
}
const lineNumber = index + 1;
const lineProps = getLineProps({ line, key: index });
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
let shouldDim = hasAnyHighlights;
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
shouldDim = false;
}
return (
<div
key={lineNumber}
{...lineProps}
className={cn(
"flex w-full justify-start transition-opacity duration-500",
lineProps.className
)}
style={{
opacity: shouldDim ? dimAmount : undefined,
...lineProps.style,
}}
>
{showLineNumbers && (
<div
className={
"mr-2 flex-none select-none text-right text-charcoal-500 transition-opacity duration-500"
}
style={{
width: `calc(8 * ${maxLineWidth / 16}rem)`,
}}
>
{lineNumber}
</div>
)}
<div className="flex-1">
{line.map((token, key) => {
const tokenProps = getTokenProps({ token, key });
return (
<span
key={key}
{...tokenProps}
style={{
color: tokenProps?.style?.color as string,
...tokenProps.style,
}}
/>
);
})}
</div>
<div className="w-4 flex-none" />
</div>
);
})
.filter(Boolean)}
</pre>
</div>
)}
</Highlight>
) : (
<div
dir="ltr"
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
style={{
maxHeight,
}}
>
<pre className="relative mr-2 p-2 font-mono text-xs leading-relaxed" dir="ltr">
{code}
</pre>
</div>
)}
</div>
);
}
);
CodeBlock.displayName = "CodeBlock";
function Chrome({ title }: { title?: string }) {
return (
<div className="grid h-7 grid-cols-[100px_auto_100px] border-b border-charcoal-800 bg-charcoal-900">
<div className="ml-2 flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-charcoal-700" />
<div className="h-3 w-3 rounded-full bg-charcoal-700" />
<div className="h-3 w-3 rounded-full bg-charcoal-700" />
</div>
<div className="flex items-center justify-center">
<div className={cn("rounded-sm px-3 py-0.5 text-xs text-charcoal-500")}>{title}</div>
</div>
<div></div>
</div>
);
}
export function TitleRow({ title }: { title: string }) {
return (
<div className="flex items-center justify-between px-4">
<Paragraph variant="base/bright" className="w-full border-b border-grid-dimmed py-2.5">
{title}
</Paragraph>
</div>
);
}