| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
- import { EditorView, basicSetup } from 'codemirror';
- import { EditorState, Compartment } from '@codemirror/state';
- import { json, jsonParseLinter } from '@codemirror/lang-json';
- import { lintGutter, linter } from '@codemirror/lint';
- import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
- import { syntaxHighlighting } from '@codemirror/language';
- import { keymap } from '@codemirror/view';
- import { indentWithTab } from '@codemirror/commands';
- import { useTheme } from '@/hooks/useTheme';
- import './JsonEditor.css';
- export interface JsonEditorProps {
- value: string;
- onChange?: (next: string) => void;
- minHeight?: string;
- maxHeight?: string;
- readOnly?: boolean;
- }
- export interface JsonEditorHandle {
- focus: () => void;
- }
- interface DarkPalette {
- bg: string;
- panelBg: string;
- activeBg: string;
- border: string;
- selection: string;
- }
- function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalette) {
- return EditorView.theme(
- {
- '&': { color: '#dcdcdc', backgroundColor: bg },
- '.cm-content': { caretColor: '#dcdcdc' },
- '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
- '.cm-gutters': {
- backgroundColor: bg,
- borderRight: `1px solid ${border}`,
- color: '#6a6a6a',
- },
- '.cm-activeLine': { backgroundColor: activeBg },
- '.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
- '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
- { backgroundColor: selection },
- '.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
- '.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
- '.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
- '.cm-tooltip': {
- backgroundColor: panelBg,
- border: `1px solid ${border}`,
- color: '#dcdcdc',
- },
- },
- { dark: true },
- );
- }
- const darkTheme = buildDarkTheme({
- bg: '#1e1e1e',
- panelBg: '#2d2d30',
- activeBg: '#252526',
- border: '#3a3a3c',
- selection: '#3a3a3c',
- });
- const ultraDarkTheme = buildDarkTheme({
- bg: '#0a0a0a',
- panelBg: '#141414',
- activeBg: '#141414',
- border: '#1f1f1f',
- selection: '#2a2a2a',
- });
- function themeExtension(isDark: boolean, isUltra: boolean) {
- if (!isDark) return [];
- const chrome = isUltra ? ultraDarkTheme : darkTheme;
- return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
- }
- const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEditor(
- { value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false },
- ref,
- ) {
- const hostRef = useRef<HTMLDivElement | null>(null);
- const viewRef = useRef<EditorView | null>(null);
- const themeCompartmentRef = useRef<Compartment>(new Compartment());
- const readonlyCompartmentRef = useRef<Compartment>(new Compartment());
- const onChangeRef = useRef(onChange);
- const valueRef = useRef(value);
- const { isDark, isUltra } = useTheme();
- useEffect(() => {
- onChangeRef.current = onChange;
- }, [onChange]);
- useImperativeHandle(ref, () => ({
- focus: () => viewRef.current?.focus(),
- }));
- useEffect(() => {
- if (!hostRef.current) return;
- const updateListener = EditorView.updateListener.of((u) => {
- if (!u.docChanged) return;
- const next = u.state.doc.toString();
- if (next === valueRef.current) return;
- valueRef.current = next;
- onChangeRef.current?.(next);
- });
- const view = new EditorView({
- parent: hostRef.current,
- state: EditorState.create({
- doc: value,
- extensions: [
- basicSetup,
- keymap.of([indentWithTab]),
- json(),
- linter(jsonParseLinter()),
- lintGutter(),
- EditorView.lineWrapping,
- updateListener,
- themeCompartmentRef.current.of(themeExtension(isDark, isUltra)),
- readonlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
- EditorView.theme({
- '&': { height: '100%' },
- '.cm-scroller': {
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
- fontSize: '12px',
- minHeight,
- maxHeight,
- },
- }),
- ],
- }),
- });
- viewRef.current = view;
- return () => {
- view.destroy();
- viewRef.current = null;
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
- useEffect(() => {
- const view = viewRef.current;
- if (!view) return;
- const current = view.state.doc.toString();
- if (value === current) return;
- valueRef.current = value;
- view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
- }, [value]);
- useEffect(() => {
- const view = viewRef.current;
- if (!view) return;
- view.dispatch({
- effects: themeCompartmentRef.current.reconfigure(themeExtension(isDark, isUltra)),
- });
- }, [isDark, isUltra]);
- useEffect(() => {
- const view = viewRef.current;
- if (!view) return;
- view.dispatch({
- effects: readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
- });
- }, [readOnly]);
- return <div ref={hostRef} className="json-editor-host" />;
- });
- export default JsonEditor;
|