import { json as jsonLang, jsonParseLinter } from "@codemirror/lang-json"; import type { EditorView, ViewUpdate } from "@codemirror/view"; import { CheckIcon, ClipboardIcon, TrashIcon } from "@heroicons/react/20/solid"; import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror"; import { useCodeMirror } from "@uiw/react-codemirror"; import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "~/utils/cn"; import { Button } from "../primitives/Buttons"; import { getEditorSetup } from "./codeMirrorSetup"; import { darkTheme } from "./codeMirrorTheme"; import { linter, lintGutter, type Diagnostic } from "@codemirror/lint"; export interface JSONEditorProps extends Omit { defaultValue?: string; language?: "json"; readOnly?: boolean; onChange?: (value: string) => void; onUpdate?: (update: ViewUpdate) => void; onBlur?: (code: string) => void; showCopyButton?: boolean; showClearButton?: boolean; linterEnabled?: boolean; allowEmpty?: boolean; additionalActions?: React.ReactNode; } const languages = { json: jsonLang, }; function emptyAwareJsonLinter() { return (view: EditorView): Diagnostic[] => { const content = view.state.doc.toString().trim(); // return no errors if content is empty if (!content) { return []; } return jsonParseLinter()(view); }; } type JSONEditorDefaultProps = Partial; const defaultProps: JSONEditorDefaultProps = { language: "json", readOnly: true, basicSetup: false, linterEnabled: true, allowEmpty: true, }; export function JSONEditor(opts: JSONEditorProps) { const { defaultValue = "", language, readOnly, onChange, onUpdate, onBlur, basicSetup, autoFocus, showCopyButton = true, showClearButton = true, linterEnabled, allowEmpty, additionalActions, } = { ...defaultProps, ...opts, }; const extensions = getEditorSetup(); if (!language) throw new Error("language is required"); const languageExtension = languages[language]; extensions.push(languageExtension()); if (linterEnabled) { extensions.push(lintGutter()); switch (language) { case "json": { extensions.push(allowEmpty ? linter(emptyAwareJsonLinter()) : linter(jsonParseLinter())); break; } default: language satisfies never; } } const editor = useRef(null); const settings: Omit = { ...opts, container: editor.current, extensions, editable: !readOnly, contentEditable: !readOnly, value: defaultValue, autoFocus, theme: darkTheme(), indentWithTab: false, basicSetup, onChange, onUpdate, }; const { setContainer, view } = useCodeMirror(settings); const [copied, setCopied] = useState(false); useEffect(() => { if (editor.current) { setContainer(editor.current); } }, [setContainer]); //if the defaultValue changes update the editor useEffect(() => { if (view !== undefined) { if (view.state.doc.toString() === defaultValue) return; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: defaultValue }, }); } }, [defaultValue, view]); const clear = () => { if (view === undefined) return; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: undefined }, }); onChange?.(""); }; const copy = useCallback(() => { if (view === undefined) return; navigator.clipboard.writeText(view.state.doc.toString()); setCopied(true); setTimeout(() => { setCopied(false); }, 1500); }, [view]); const showButtons = showClearButton || showCopyButton; return (
{showButtons && (
{additionalActions && additionalActions} {showClearButton && ( )} {showCopyButton && ( )}
)}
{ if (!onBlur) return; onBlur(editor.current?.textContent ?? ""); }} />
); }