100 lines
3.1 KiB
TypeScript
100 lines
3.1 KiB
TypeScript
import * as React from "react";
|
|
import { cn } from "@/lib/utils.ts";
|
|
|
|
export interface CodeTextareaProps
|
|
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "spellCheck"> {
|
|
showLineNumbers?: boolean;
|
|
/** Applied to the outer bordered container when line numbers are shown. */
|
|
wrapperClassName?: string;
|
|
}
|
|
|
|
function countLines(value: string): number {
|
|
if (!value) return 1;
|
|
return value.split("\n").length;
|
|
}
|
|
|
|
const CodeTextarea = React.forwardRef<HTMLTextAreaElement, CodeTextareaProps>(
|
|
({ className, wrapperClassName, value, showLineNumbers = true, onScroll, ...props }, ref) => {
|
|
const gutterRef = React.useRef<HTMLDivElement>(null);
|
|
const text = typeof value === "string" ? value : String(value ?? "");
|
|
const lineCount = countLines(text);
|
|
const lineNumbers = React.useMemo(
|
|
() => Array.from({ length: lineCount }, (_, i) => i + 1),
|
|
[lineCount],
|
|
);
|
|
const gutterWidthCh = Math.max(2, String(lineCount).length) + 1;
|
|
|
|
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
|
if (gutterRef.current) {
|
|
gutterRef.current.scrollTop = e.currentTarget.scrollTop;
|
|
}
|
|
onScroll?.(e);
|
|
};
|
|
|
|
const editorClass = cn(
|
|
"w-full flex-1 resize-none border-0 bg-transparent px-2 py-2 font-mono text-xs leading-5",
|
|
"placeholder:text-muted-foreground focus-visible:outline-none",
|
|
"whitespace-pre overflow-auto",
|
|
className,
|
|
);
|
|
|
|
if (!showLineNumbers) {
|
|
return (
|
|
<textarea
|
|
ref={ref}
|
|
value={value}
|
|
className={cn(
|
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs leading-5",
|
|
"placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
"whitespace-pre",
|
|
className,
|
|
)}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
data-gramm="false"
|
|
onScroll={onScroll}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex w-full overflow-hidden rounded-md border border-input bg-background transition-colors",
|
|
"focus-within:border-ring",
|
|
wrapperClassName,
|
|
)}
|
|
>
|
|
<div
|
|
ref={gutterRef}
|
|
aria-hidden
|
|
className="shrink-0 overflow-hidden border-r border-border/60 bg-muted/30 py-2 pl-2 pr-1.5 select-none"
|
|
style={{ width: `${gutterWidthCh}ch` }}
|
|
>
|
|
<pre className="font-mono text-[11px] leading-5 text-muted-foreground text-right m-0">
|
|
{lineNumbers.join("\n")}
|
|
</pre>
|
|
</div>
|
|
<textarea
|
|
ref={ref}
|
|
value={value}
|
|
className={editorClass}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
data-gramm="false"
|
|
onScroll={handleScroll}
|
|
{...props}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
CodeTextarea.displayName = "CodeTextarea";
|
|
|
|
export { CodeTextarea };
|