aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/editor/EditorArea.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/editor/EditorArea.tsx')
-rw-r--r--subprojects/frontend/src/editor/EditorArea.tsx152
1 files changed, 152 insertions, 0 deletions
diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx
new file mode 100644
index 00000000..dba20f6e
--- /dev/null
+++ b/subprojects/frontend/src/editor/EditorArea.tsx
@@ -0,0 +1,152 @@
1import { Command, EditorView } from '@codemirror/view';
2import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
3import { closeLintPanel, openLintPanel } from '@codemirror/lint';
4import { observer } from 'mobx-react-lite';
5import React, {
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10} from 'react';
11
12import { EditorParent } from './EditorParent';
13import { useRootStore } from '../RootStore';
14import { getLogger } from '../utils/logger';
15
16const log = getLogger('editor.EditorArea');
17
18function usePanel(
19 panelId: string,
20 stateToSet: boolean,
21 editorView: EditorView | null,
22 openCommand: Command,
23 closeCommand: Command,
24 closeCallback: () => void,
25) {
26 const [cachedViewState, setCachedViewState] = useState<boolean>(false);
27 useEffect(() => {
28 if (editorView === null || cachedViewState === stateToSet) {
29 return;
30 }
31 if (stateToSet) {
32 openCommand(editorView);
33 const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`;
34 const closeButton = editorView.dom.querySelector(buttonQuery);
35 if (closeButton) {
36 log.debug('Addig close button callback to', panelId, 'panel');
37 // We must remove the event listener added by CodeMirror from the button
38 // that dispatches a transaction without going through `EditorStorre`.
39 // Cloning a DOM node removes event listeners,
40 // see https://stackoverflow.com/a/9251864
41 const closeButtonWithoutListeners = closeButton.cloneNode(true);
42 closeButtonWithoutListeners.addEventListener('click', (event) => {
43 closeCallback();
44 event.preventDefault();
45 });
46 closeButton.replaceWith(closeButtonWithoutListeners);
47 } else {
48 log.error('Opened', panelId, 'panel has no close button');
49 }
50 } else {
51 closeCommand(editorView);
52 }
53 setCachedViewState(stateToSet);
54 }, [
55 stateToSet,
56 editorView,
57 cachedViewState,
58 panelId,
59 openCommand,
60 closeCommand,
61 closeCallback,
62 ]);
63 return setCachedViewState;
64}
65
66function fixCodeMirrorAccessibility(editorView: EditorView) {
67 // Reported by Lighthouse 8.3.0.
68 const { contentDOM } = editorView;
69 contentDOM.removeAttribute('aria-expanded');
70 contentDOM.setAttribute('aria-label', 'Code editor');
71}
72
73export const EditorArea = observer(() => {
74 const { editorStore } = useRootStore();
75 const editorParentRef = useRef<HTMLDivElement | null>(null);
76 const [editorViewState, setEditorViewState] = useState<EditorView | null>(null);
77
78 const setSearchPanelOpen = usePanel(
79 'search',
80 editorStore.showSearchPanel,
81 editorViewState,
82 openSearchPanel,
83 closeSearchPanel,
84 useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]),
85 );
86
87 const setLintPanelOpen = usePanel(
88 'panel-lint',
89 editorStore.showLintPanel,
90 editorViewState,
91 openLintPanel,
92 closeLintPanel,
93 useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]),
94 );
95
96 useEffect(() => {
97 if (editorParentRef.current === null) {
98 return () => {
99 // Nothing to clean up.
100 };
101 }
102
103 const editorView = new EditorView({
104 state: editorStore.state,
105 parent: editorParentRef.current,
106 dispatch: (transaction) => {
107 editorStore.onTransaction(transaction);
108 editorView.update([transaction]);
109 if (editorView.state !== editorStore.state) {
110 log.error(
111 'Failed to synchronize editor state - store state:',
112 editorStore.state,
113 'view state:',
114 editorView.state,
115 );
116 }
117 },
118 });
119 fixCodeMirrorAccessibility(editorView);
120 setEditorViewState(editorView);
121 setSearchPanelOpen(false);
122 setLintPanelOpen(false);
123 // `dispatch` is bound to the view instance,
124 // so it does not have to be called as a method.
125 // eslint-disable-next-line @typescript-eslint/unbound-method
126 editorStore.updateDispatcher(editorView.dispatch);
127 log.info('Editor created');
128
129 return () => {
130 editorStore.updateDispatcher(null);
131 editorView.destroy();
132 log.info('Editor destroyed');
133 };
134 }, [
135 editorParentRef,
136 editorStore,
137 setSearchPanelOpen,
138 setLintPanelOpen,
139 ]);
140
141 return (
142 <EditorParent
143 className="dark"
144 sx={{
145 '.cm-lineNumbers': editorStore.showLineNumbers ? {} : {
146 display: 'none !important',
147 },
148 }}
149 ref={editorParentRef}
150 />
151 );
152});