v3
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user