aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/editor/EditorArea.tsx
blob: d430561045fc0f3d70636d4e80348e7b48438f3b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import { closeLintPanel, openLintPanel } from '@codemirror/lint';
import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
import { type Command, EditorView } from '@codemirror/view';
import { observer } from 'mobx-react-lite';
import React, { useCallback, useEffect, useRef, useState } from 'react';

import { useRootStore } from '../RootStore';
import getLogger from '../utils/getLogger';

import EditorParent from './EditorParent';

const log = getLogger('editor.EditorArea');

function usePanel(
  panelId: string,
  stateToSet: boolean,
  editorView: EditorView | null,
  openCommand: Command,
  closeCommand: Command,
  closeCallback: () => void,
) {
  const [cachedViewState, setCachedViewState] = useState<boolean>(false);
  useEffect(() => {
    if (editorView === null || cachedViewState === stateToSet) {
      return;
    }
    if (stateToSet) {
      openCommand(editorView);
      const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`;
      const closeButton = editorView.dom.querySelector(buttonQuery);
      if (closeButton) {
        log.debug('Addig close button callback to', panelId, 'panel');
        // We must remove the event listener added by CodeMirror from the button
        // that dispatches a transaction without going through `EditorStorre`.
        // Cloning a DOM node removes event listeners,
        // see https://stackoverflow.com/a/9251864
        const closeButtonWithoutListeners = closeButton.cloneNode(true);
        closeButtonWithoutListeners.addEventListener('click', (event) => {
          closeCallback();
          event.preventDefault();
        });
        closeButton.replaceWith(closeButtonWithoutListeners);
      } else {
        log.error('Opened', panelId, 'panel has no close button');
      }
    } else {
      closeCommand(editorView);
    }
    setCachedViewState(stateToSet);
  }, [
    stateToSet,
    editorView,
    cachedViewState,
    panelId,
    openCommand,
    closeCommand,
    closeCallback,
  ]);
  return setCachedViewState;
}

function fixCodeMirrorAccessibility(editorView: EditorView) {
  // Reported by Lighthouse 8.3.0.
  const { contentDOM } = editorView;
  contentDOM.removeAttribute('aria-expanded');
  contentDOM.setAttribute('aria-label', 'Code editor');
}

function EditorArea(): JSX.Element {
  const { editorStore } = useRootStore();
  const editorParentRef = useRef<HTMLDivElement | null>(null);
  const [editorViewState, setEditorViewState] = useState<EditorView | null>(
    null,
  );

  const setSearchPanelOpen = usePanel(
    'search',
    editorStore.showSearchPanel,
    editorViewState,
    openSearchPanel,
    closeSearchPanel,
    useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]),
  );

  const setLintPanelOpen = usePanel(
    'panel-lint',
    editorStore.showLintPanel,
    editorViewState,
    openLintPanel,
    closeLintPanel,
    useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]),
  );

  useEffect(() => {
    if (editorParentRef.current === null) {
      return () => {
        // Nothing to clean up.
      };
    }

    const editorView = new EditorView({
      state: editorStore.state,
      parent: editorParentRef.current,
      dispatch: (transaction) => {
        editorStore.onTransaction(transaction);
        editorView.update([transaction]);
        if (editorView.state !== editorStore.state) {
          log.error(
            'Failed to synchronize editor state - store state:',
            editorStore.state,
            'view state:',
            editorView.state,
          );
        }
      },
    });
    fixCodeMirrorAccessibility(editorView);
    setEditorViewState(editorView);
    setSearchPanelOpen(false);
    setLintPanelOpen(false);
    // `dispatch` is bound to the view instance,
    // so it does not have to be called as a method.
    // eslint-disable-next-line @typescript-eslint/unbound-method
    editorStore.updateDispatcher(editorView.dispatch);
    log.info('Editor created');

    return () => {
      editorStore.updateDispatcher(null);
      editorView.destroy();
      log.info('Editor destroyed');
    };
  }, [editorStore, setSearchPanelOpen, setLintPanelOpen]);

  return (
    <EditorParent
      className="dark"
      showLineNumbers={editorStore.showLineNumbers}
      ref={editorParentRef}
    />
  );
}

export default observer(EditorArea);