diff options
Diffstat (limited to 'subprojects/language-web/src/main/js/editor/EditorArea.tsx')
-rw-r--r-- | subprojects/language-web/src/main/js/editor/EditorArea.tsx | 152 |
1 files changed, 152 insertions, 0 deletions
diff --git a/subprojects/language-web/src/main/js/editor/EditorArea.tsx b/subprojects/language-web/src/main/js/editor/EditorArea.tsx new file mode 100644 index 00000000..dba20f6e --- /dev/null +++ b/subprojects/language-web/src/main/js/editor/EditorArea.tsx | |||
@@ -0,0 +1,152 @@ | |||
1 | import { Command, EditorView } from '@codemirror/view'; | ||
2 | import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; | ||
3 | import { closeLintPanel, openLintPanel } from '@codemirror/lint'; | ||
4 | import { observer } from 'mobx-react-lite'; | ||
5 | import React, { | ||
6 | useCallback, | ||
7 | useEffect, | ||
8 | useRef, | ||
9 | useState, | ||
10 | } from 'react'; | ||
11 | |||
12 | import { EditorParent } from './EditorParent'; | ||
13 | import { useRootStore } from '../RootStore'; | ||
14 | import { getLogger } from '../utils/logger'; | ||
15 | |||
16 | const log = getLogger('editor.EditorArea'); | ||
17 | |||
18 | function 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 | |||
66 | function 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 | |||
73 | export 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 | }); | ||