Spaces:
Build error
Build error
| 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> | |
| ); | |
| } | |