76 lines
2.0 KiB
TypeScript
76 lines
2.0 KiB
TypeScript
|
|
import { useMemo, useState } from "react";
|
||
|
|
import { cn } from "@/lib/cn";
|
||
|
|
|
||
|
|
export interface JsonEditorProps {
|
||
|
|
value: unknown;
|
||
|
|
onChange: (value: unknown) => void;
|
||
|
|
className?: string;
|
||
|
|
readOnly?: boolean;
|
||
|
|
rows?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function JsonEditor({ value, onChange, className, readOnly = false, rows = 16 }: JsonEditorProps) {
|
||
|
|
const initialText = useMemo(() => safeStringify(value), [value]);
|
||
|
|
const [text, setText] = useState<string>(initialText);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
// Re-sync external value changes if differs from current parsed text
|
||
|
|
useMemo(() => {
|
||
|
|
const external = safeStringify(value);
|
||
|
|
if (external !== text && !error) {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(text);
|
||
|
|
if (safeStringify(parsed) !== external) {
|
||
|
|
setText(external);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// user is editing — keep text
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
|
|
}, [value]);
|
||
|
|
|
||
|
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
|
|
const next = e.target.value;
|
||
|
|
setText(next);
|
||
|
|
if (readOnly) return;
|
||
|
|
if (next.trim() === "") {
|
||
|
|
setError(null);
|
||
|
|
onChange(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(next);
|
||
|
|
setError(null);
|
||
|
|
onChange(parsed);
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : "Invalid JSON");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn("w-full", className)}>
|
||
|
|
<textarea
|
||
|
|
value={text}
|
||
|
|
onChange={handleChange}
|
||
|
|
readOnly={readOnly}
|
||
|
|
rows={rows}
|
||
|
|
spellCheck={false}
|
||
|
|
className={cn(
|
||
|
|
"input font-mono text-xs",
|
||
|
|
error && "border-err focus:border-err focus:ring-err",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
{error && <p className="mt-1 text-xs text-err">{error}</p>}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function safeStringify(v: unknown): string {
|
||
|
|
try {
|
||
|
|
return JSON.stringify(v ?? null, null, 2);
|
||
|
|
} catch {
|
||
|
|
return String(v ?? "");
|
||
|
|
}
|
||
|
|
}
|