2025-09-18 08:35:09 +08:00
|
|
|
|
import React, { useRef, useEffect, useMemo } from "react";
|
2025-09-06 09:30:09 +08:00
|
|
|
|
import { EditorView, basicSetup } from "codemirror";
|
|
|
|
|
|
import { json } from "@codemirror/lang-json";
|
2025-10-15 09:15:25 +08:00
|
|
|
|
import { javascript } from "@codemirror/lang-javascript";
|
2025-09-06 09:30:09 +08:00
|
|
|
|
import { oneDark } from "@codemirror/theme-one-dark";
|
|
|
|
|
|
import { EditorState } from "@codemirror/state";
|
|
|
|
|
|
import { placeholder } from "@codemirror/view";
|
2025-09-18 08:35:09 +08:00
|
|
|
|
import { linter, Diagnostic } from "@codemirror/lint";
|
2025-10-07 23:31:00 +08:00
|
|
|
|
import { useTranslation } from "react-i18next";
|
2025-11-01 21:05:01 +08:00
|
|
|
|
import { Wand2 } from "lucide-react";
|
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
|
import { formatJSON } from "@/utils/formatters";
|
2025-09-06 09:30:09 +08:00
|
|
|
|
|
|
|
|
|
|
interface JsonEditorProps {
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
|
darkMode?: boolean;
|
|
|
|
|
|
rows?: number;
|
2025-09-18 08:35:09 +08:00
|
|
|
|
showValidation?: boolean;
|
2025-10-15 09:15:25 +08:00
|
|
|
|
language?: "json" | "javascript";
|
|
|
|
|
|
height?: string;
|
2025-09-06 09:30:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const JsonEditor: React.FC<JsonEditorProps> = ({
|
|
|
|
|
|
value,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
placeholder: placeholderText = "",
|
|
|
|
|
|
darkMode = false,
|
|
|
|
|
|
rows = 12,
|
2025-09-18 08:35:09 +08:00
|
|
|
|
showValidation = true,
|
2025-10-15 09:15:25 +08:00
|
|
|
|
language = "json",
|
|
|
|
|
|
height,
|
2025-09-06 09:30:09 +08:00
|
|
|
|
}) => {
|
2025-10-07 23:31:00 +08:00
|
|
|
|
const { t } = useTranslation();
|
2025-09-06 09:30:09 +08:00
|
|
|
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const viewRef = useRef<EditorView | null>(null);
|
|
|
|
|
|
|
2025-09-18 08:35:09 +08:00
|
|
|
|
// JSON linter 函数
|
|
|
|
|
|
const jsonLinter = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
linter((view) => {
|
|
|
|
|
|
const diagnostics: Diagnostic[] = [];
|
2025-10-15 09:15:25 +08:00
|
|
|
|
if (!showValidation || language !== "json") return diagnostics;
|
2025-09-18 08:35:09 +08:00
|
|
|
|
|
|
|
|
|
|
const doc = view.state.doc.toString();
|
|
|
|
|
|
if (!doc.trim()) return diagnostics;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(doc);
|
|
|
|
|
|
// 检查是否是JSON对象
|
|
|
|
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
|
|
|
|
// 格式正确
|
|
|
|
|
|
} else {
|
|
|
|
|
|
diagnostics.push({
|
|
|
|
|
|
from: 0,
|
|
|
|
|
|
to: doc.length,
|
|
|
|
|
|
severity: "error",
|
2025-10-07 23:31:00 +08:00
|
|
|
|
message: t("jsonEditor.mustBeObject"),
|
2025-09-18 08:35:09 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 简单处理JSON解析错误
|
2025-10-08 21:22:56 +08:00
|
|
|
|
const message =
|
|
|
|
|
|
e instanceof SyntaxError ? e.message : t("jsonEditor.invalidJson");
|
2025-09-18 08:35:09 +08:00
|
|
|
|
diagnostics.push({
|
|
|
|
|
|
from: 0,
|
|
|
|
|
|
to: doc.length,
|
|
|
|
|
|
severity: "error",
|
|
|
|
|
|
message,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return diagnostics;
|
|
|
|
|
|
}),
|
2025-10-15 09:15:25 +08:00
|
|
|
|
[showValidation, language, t],
|
2025-09-18 08:35:09 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2025-09-06 09:30:09 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!editorRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 创建编辑器扩展
|
2025-10-15 09:15:25 +08:00
|
|
|
|
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
|
2025-10-21 10:49:30 +08:00
|
|
|
|
|
|
|
|
|
|
// 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题
|
|
|
|
|
|
const baseTheme = EditorView.baseTheme({
|
|
|
|
|
|
"&light .cm-editor, &dark .cm-editor": {
|
|
|
|
|
|
border: "1px solid hsl(var(--border))",
|
|
|
|
|
|
borderRadius: "0.5rem",
|
|
|
|
|
|
},
|
|
|
|
|
|
"&light .cm-editor.cm-focused, &dark .cm-editor.cm-focused": {
|
|
|
|
|
|
outline: "none",
|
|
|
|
|
|
borderColor: "hsl(var(--primary))",
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 theme 定义尺寸和字体样式
|
2025-09-06 09:30:09 +08:00
|
|
|
|
const sizingTheme = EditorView.theme({
|
2025-10-15 09:15:25 +08:00
|
|
|
|
"&": height ? { height } : { minHeight: `${minHeightPx}px` },
|
2025-09-06 09:30:09 +08:00
|
|
|
|
".cm-scroller": { overflow: "auto" },
|
|
|
|
|
|
".cm-content": {
|
|
|
|
|
|
fontFamily:
|
|
|
|
|
|
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
|
|
|
|
fontSize: "14px",
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const extensions = [
|
|
|
|
|
|
basicSetup,
|
2025-10-15 09:15:25 +08:00
|
|
|
|
language === "javascript" ? javascript() : json(),
|
2025-09-06 09:30:09 +08:00
|
|
|
|
placeholder(placeholderText || ""),
|
2025-10-21 10:49:30 +08:00
|
|
|
|
baseTheme,
|
2025-09-06 09:30:09 +08:00
|
|
|
|
sizingTheme,
|
2025-09-18 08:35:09 +08:00
|
|
|
|
jsonLinter,
|
2025-09-06 09:30:09 +08:00
|
|
|
|
EditorView.updateListener.of((update) => {
|
|
|
|
|
|
if (update.docChanged) {
|
|
|
|
|
|
const newValue = update.state.doc.toString();
|
|
|
|
|
|
onChange(newValue);
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 如果启用深色模式,添加深色主题
|
|
|
|
|
|
if (darkMode) {
|
|
|
|
|
|
extensions.push(oneDark);
|
2025-10-21 10:49:30 +08:00
|
|
|
|
// 在 oneDark 之后强制覆盖边框样式
|
|
|
|
|
|
extensions.push(
|
|
|
|
|
|
EditorView.theme({
|
|
|
|
|
|
".cm-editor": {
|
|
|
|
|
|
border: "1px solid hsl(var(--border))",
|
|
|
|
|
|
borderRadius: "0.5rem",
|
|
|
|
|
|
},
|
|
|
|
|
|
".cm-editor.cm-focused": {
|
|
|
|
|
|
outline: "none",
|
|
|
|
|
|
borderColor: "hsl(var(--primary))",
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2025-09-06 09:30:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建初始状态
|
|
|
|
|
|
const state = EditorState.create({
|
|
|
|
|
|
doc: value,
|
|
|
|
|
|
extensions,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 创建编辑器视图
|
|
|
|
|
|
const view = new EditorView({
|
|
|
|
|
|
state,
|
|
|
|
|
|
parent: editorRef.current,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
viewRef.current = view;
|
|
|
|
|
|
|
|
|
|
|
|
// 清理函数
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
view.destroy();
|
|
|
|
|
|
viewRef.current = null;
|
|
|
|
|
|
};
|
2025-10-15 09:15:25 +08:00
|
|
|
|
}, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
2025-09-06 09:30:09 +08:00
|
|
|
|
|
|
|
|
|
|
// 当 value 从外部改变时更新编辑器内容
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
|
|
|
|
|
|
const transaction = viewRef.current.state.update({
|
|
|
|
|
|
changes: {
|
|
|
|
|
|
from: 0,
|
|
|
|
|
|
to: viewRef.current.state.doc.length,
|
|
|
|
|
|
insert: value,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
viewRef.current.dispatch(transaction);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [value]);
|
|
|
|
|
|
|
2025-11-01 21:05:01 +08:00
|
|
|
|
// 格式化处理函数
|
|
|
|
|
|
const handleFormat = () => {
|
|
|
|
|
|
if (!viewRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
const currentValue = viewRef.current.state.doc.toString();
|
|
|
|
|
|
if (!currentValue.trim()) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const formatted = formatJSON(currentValue);
|
|
|
|
|
|
onChange(formatted);
|
|
|
|
|
|
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const errorMessage =
|
|
|
|
|
|
error instanceof Error ? error.message : String(error);
|
|
|
|
|
|
toast.error(
|
|
|
|
|
|
t("common.formatError", {
|
|
|
|
|
|
defaultValue: "格式化失败:{{error}}",
|
|
|
|
|
|
error: errorMessage,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ width: "100%" }}>
|
|
|
|
|
|
<div ref={editorRef} style={{ width: "100%" }} />
|
|
|
|
|
|
{language === "json" && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleFormat}
|
|
|
|
|
|
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Wand2 className="w-3.5 h-3.5" />
|
|
|
|
|
|
{t("common.format", { defaultValue: "格式化" })}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2025-09-06 09:30:09 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default JsonEditor;
|