JsonEditor.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { EditorView, basicSetup } from 'codemirror';
  4. import { EditorState, Compartment } from '@codemirror/state';
  5. import { json, jsonParseLinter } from '@codemirror/lang-json';
  6. import { lintGutter, linter } from '@codemirror/lint';
  7. import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
  8. import { syntaxHighlighting } from '@codemirror/language';
  9. import { keymap } from '@codemirror/view';
  10. import { indentWithTab } from '@codemirror/commands';
  11. import { useTheme } from '@/hooks/useTheme';
  12. import './JsonEditor.css';
  13. export interface JsonEditorProps {
  14. value: string;
  15. onChange?: (next: string) => void;
  16. minHeight?: string;
  17. maxHeight?: string;
  18. readOnly?: boolean;
  19. }
  20. export interface JsonEditorHandle {
  21. focus: () => void;
  22. }
  23. interface DarkPalette {
  24. bg: string;
  25. panelBg: string;
  26. activeBg: string;
  27. border: string;
  28. selection: string;
  29. }
  30. function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalette) {
  31. return EditorView.theme(
  32. {
  33. '&': { color: '#dcdcdc', backgroundColor: bg },
  34. '.cm-content': { caretColor: '#dcdcdc' },
  35. '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
  36. '.cm-gutters': {
  37. backgroundColor: bg,
  38. borderRight: `1px solid ${border}`,
  39. color: '#6a6a6a',
  40. },
  41. '.cm-activeLine': { backgroundColor: activeBg },
  42. '.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
  43. '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
  44. { backgroundColor: selection },
  45. '.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
  46. '.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
  47. '.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
  48. '.cm-tooltip': {
  49. backgroundColor: panelBg,
  50. border: `1px solid ${border}`,
  51. color: '#dcdcdc',
  52. },
  53. },
  54. { dark: true },
  55. );
  56. }
  57. const darkTheme = buildDarkTheme({
  58. bg: '#1e1e1e',
  59. panelBg: '#2d2d30',
  60. activeBg: '#252526',
  61. border: '#3a3a3c',
  62. selection: '#3a3a3c',
  63. });
  64. const ultraDarkTheme = buildDarkTheme({
  65. bg: '#0a0a0a',
  66. panelBg: '#141414',
  67. activeBg: '#141414',
  68. border: '#1f1f1f',
  69. selection: '#2a2a2a',
  70. });
  71. function themeExtension(isDark: boolean, isUltra: boolean) {
  72. if (!isDark) return [];
  73. const chrome = isUltra ? ultraDarkTheme : darkTheme;
  74. return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
  75. }
  76. const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEditor(
  77. { value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false },
  78. ref,
  79. ) {
  80. const hostRef = useRef<HTMLDivElement | null>(null);
  81. const viewRef = useRef<EditorView | null>(null);
  82. const themeCompartmentRef = useRef<Compartment>(new Compartment());
  83. const readonlyCompartmentRef = useRef<Compartment>(new Compartment());
  84. const onChangeRef = useRef(onChange);
  85. const valueRef = useRef(value);
  86. const { isDark, isUltra } = useTheme();
  87. const { t } = useTranslation();
  88. useEffect(() => {
  89. onChangeRef.current = onChange;
  90. }, [onChange]);
  91. useImperativeHandle(ref, () => ({
  92. focus: () => viewRef.current?.focus(),
  93. }));
  94. useEffect(() => {
  95. if (!hostRef.current) return;
  96. const updateListener = EditorView.updateListener.of((u) => {
  97. if (!u.docChanged) return;
  98. const next = u.state.doc.toString();
  99. if (next === valueRef.current) return;
  100. valueRef.current = next;
  101. onChangeRef.current?.(next);
  102. });
  103. const view = new EditorView({
  104. parent: hostRef.current,
  105. state: EditorState.create({
  106. doc: value,
  107. extensions: [
  108. basicSetup,
  109. EditorView.contentAttributes.of({ 'aria-label': t('jsonEditor') }),
  110. keymap.of([indentWithTab]),
  111. json(),
  112. linter(jsonParseLinter()),
  113. lintGutter(),
  114. EditorView.lineWrapping,
  115. updateListener,
  116. themeCompartmentRef.current.of(themeExtension(isDark, isUltra)),
  117. readonlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
  118. EditorView.theme({
  119. '&': { height: '100%' },
  120. '.cm-scroller': {
  121. fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
  122. fontSize: '12px',
  123. minHeight,
  124. maxHeight,
  125. },
  126. }),
  127. ],
  128. }),
  129. });
  130. viewRef.current = view;
  131. return () => {
  132. view.destroy();
  133. viewRef.current = null;
  134. };
  135. // eslint-disable-next-line react-hooks/exhaustive-deps
  136. }, []);
  137. useEffect(() => {
  138. const view = viewRef.current;
  139. if (!view) return;
  140. const current = view.state.doc.toString();
  141. if (value === current) return;
  142. valueRef.current = value;
  143. view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
  144. }, [value]);
  145. useEffect(() => {
  146. const view = viewRef.current;
  147. if (!view) return;
  148. view.dispatch({
  149. effects: themeCompartmentRef.current.reconfigure(themeExtension(isDark, isUltra)),
  150. });
  151. }, [isDark, isUltra]);
  152. useEffect(() => {
  153. const view = viewRef.current;
  154. if (!view) return;
  155. view.dispatch({
  156. effects: readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
  157. });
  158. }, [readOnly]);
  159. return <div ref={hostRef} className="json-editor-host" aria-label={t('jsonEditor')} />;
  160. });
  161. export default JsonEditor;