JsonEditor.tsx 5.2 KB

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