openoperator / components /shared /preview /json-error-highlighter.tsx
Leon4gr45's picture
Deploy to clean space
75fefa7 verified
import { cn } from "@/utils/cn";
import React, { useRef, useState, useEffect, useCallback } from "react";
export function JsonErrorHighlighter({
value,
error,
onChange,
onBlur,
className,
style,
}: {
value: string;
error: { line?: number; column?: number; message: string } | null;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onBlur?: () => void;
className?: string;
style?: React.CSSProperties;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const preRef = useRef<HTMLPreElement>(null);
const lineNumbersRef = useRef<HTMLDivElement>(null);
const [scrollInfo, setScrollInfo] = useState({
firstVisible: 0,
lastVisible: 20,
scrollTop: 0,
lineHeight: 24,
clientHeight: 250,
});
const lines = value.split("\n");
const errorLineIdx = (error?.line ?? 1) - 1;
// Calculate visible lines on scroll or resize
const recalcVisibleLines = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24;
const scrollTop = textarea.scrollTop;
const clientHeight = textarea.clientHeight;
const firstVisible = Math.floor(scrollTop / lineHeight);
const lastVisible = Math.min(
lines.length - 1,
Math.ceil((scrollTop + clientHeight) / lineHeight),
);
setScrollInfo({
firstVisible,
lastVisible,
scrollTop,
lineHeight,
clientHeight,
});
}, [lines.length]);
useEffect(() => {
recalcVisibleLines();
// Sync overlay height with textarea
const handleResize = () => recalcVisibleLines();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [value, recalcVisibleLines]);
// Attach scroll handler
const handleScroll = () => {
recalcVisibleLines();
if (textareaRef.current && preRef.current) {
preRef.current.scrollTop = textareaRef.current.scrollTop;
preRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
};
// Only render visible lines in <pre>
const visibleLines = lines.slice(
scrollInfo.firstVisible,
scrollInfo.lastVisible + 1,
);
return (
<div
className={cn(
"w-full h-full relative font-mono text-foreground text-sm min-h-[250px] overflow-hidden focus:border-none focus-visible:border-none focus-visible:outline-none",
className,
)}
style={style}
>
{/* Highlight overlay */}
{error?.line && (
<pre
ref={preRef}
className="absolute inset-0 pointer-events-none select-none text-transparent whitespace-pre-wrap break-words focus-visible:outline-none shadow-none border-none rounded-md"
aria-hidden="true"
style={{
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "1.5",
margin: 0,
padding: "8px 12px",
paddingLeft: "0",
boxSizing: "border-box",
minHeight: "250px",
transform: `translateY(-${scrollInfo.scrollTop}px)`,
}}
>
<div style={{ height: scrollInfo.firstVisible * 1.5 + "em" }} />
{visibleLines.map((line, idx) => {
const globalIdx = idx + scrollInfo.firstVisible;
if (globalIdx === errorLineIdx) {
return (
<div
key={globalIdx}
className="bg-red-500/20"
style={{ display: "block" }}
>
{line}
</div>
);
}
return <div key={globalIdx}>{line}</div>;
})}
</pre>
)}
{/* Line numbers overlay */}
<div
ref={lineNumbersRef}
className="absolute left-0 top-0 bottom-0 pointer-events-none select-none text-muted-foreground/60 text-xs border-r border-border/50 bg-muted/20 rounded-l-md h-fit"
style={{
width: "3rem",
padding: "11px 9px",
boxSizing: "border-box",
fontFamily: "inherit",
fontSize: "0.75em",
lineHeight: "1.5",
transform: `translateY(-${scrollInfo.scrollTop}px)`,
}}
>
<div
style={{
height: scrollInfo.firstVisible * scrollInfo.lineHeight + "px",
}}
/>
{visibleLines.map((_, idx) => {
const globalIdx = idx + scrollInfo.firstVisible;
return (
<div
key={globalIdx}
className="pr-2"
style={{
height: "16px",
marginTop: idx === 0 ? 0 : "5px",
paddingTop: "2px",
}}
>
{globalIdx + 1}
</div>
);
})}
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
className={cn(
"absolute inset-0 resize-none bg-transparent border rounded-md text-black dark:text-white focus:overline-none focus:border-zinc-200 focus-visible:border-zinc-200 focus-visible:outline-none",
error?.message ? "!border-destructive" : "border-zinc-200",
)}
value={value}
onChange={onChange}
onBlur={onBlur}
onScroll={handleScroll}
spellCheck={false}
style={{
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "1.5",
margin: 0,
padding: "8px 12px 8px 4rem", // Add left padding to account for line numbers
boxSizing: "border-box",
minHeight: "250px",
background: "transparent",
color: "inherit",
zIndex: 1,
outline: "none",
boxShadow: "none",
}}
/>
{/* Error message overlay */}
{error?.message && (
<div
className="absolute left-0 right-0 bottom-0 px-3 py-1 text-xs text-white bg-red-500/90 z-10 pointer-events-none"
style={{
fontFamily: "inherit",
fontSize: "0.85em",
borderBottomLeftRadius: 6,
borderBottomRightRadius: 6,
}}
>
{error.message}
</div>
)}
</div>
);
}