Files
cc-switch/src/components/JsonEditor.tsx
Jason 87f408c163 feat(editor): add JSON format button to editors
Add one-click format functionality for JSON editors with toast notifications:

- Add format button to JsonEditor (CodeMirror)
- Add format button to CodexAuthSection (auth.json)
- Add format button to CommonConfigEditor (settings + modal)
- Add formatJSON utility function
- Add i18n keys: format, formatSuccess, formatError

Note: TOML formatting was intentionally NOT added to avoid losing comments
during parse/stringify operations. TOML validation remains available via
the existing useCodexTomlValidation hook.
2025-11-01 21:05:01 +08:00

217 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useRef, useEffect, useMemo } from "react";
import { EditorView, basicSetup } from "codemirror";
import { json } from "@codemirror/lang-json";
import { javascript } from "@codemirror/lang-javascript";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorState } from "@codemirror/state";
import { placeholder } from "@codemirror/view";
import { linter, Diagnostic } from "@codemirror/lint";
import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
darkMode?: boolean;
rows?: number;
showValidation?: boolean;
language?: "json" | "javascript";
height?: string;
}
const JsonEditor: React.FC<JsonEditorProps> = ({
value,
onChange,
placeholder: placeholderText = "",
darkMode = false,
rows = 12,
showValidation = true,
language = "json",
height,
}) => {
const { t } = useTranslation();
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
// JSON linter 函数
const jsonLinter = useMemo(
() =>
linter((view) => {
const diagnostics: Diagnostic[] = [];
if (!showValidation || language !== "json") return diagnostics;
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",
message: t("jsonEditor.mustBeObject"),
});
}
} catch (e) {
// 简单处理JSON解析错误
const message =
e instanceof SyntaxError ? e.message : t("jsonEditor.invalidJson");
diagnostics.push({
from: 0,
to: doc.length,
severity: "error",
message,
});
}
return diagnostics;
}),
[showValidation, language, t],
);
useEffect(() => {
if (!editorRef.current) return;
// 创建编辑器扩展
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
// 使用 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 定义尺寸和字体样式
const sizingTheme = EditorView.theme({
"&": height ? { height } : { minHeight: `${minHeightPx}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": {
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: "14px",
},
});
const extensions = [
basicSetup,
language === "javascript" ? javascript() : json(),
placeholder(placeholderText || ""),
baseTheme,
sizingTheme,
jsonLinter,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
onChange(newValue);
}
}),
];
// 如果启用深色模式,添加深色主题
if (darkMode) {
extensions.push(oneDark);
// 在 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))",
},
}),
);
}
// 创建初始状态
const state = EditorState.create({
doc: value,
extensions,
});
// 创建编辑器视图
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
// 清理函数
return () => {
view.destroy();
viewRef.current = null;
};
}, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建
// 当 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]);
// 格式化处理函数
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>
);
};
export default JsonEditor;