diff options
Diffstat (limited to 'language-web/src/main/js/editor/EditorArea.tsx')
-rw-r--r-- | language-web/src/main/js/editor/EditorArea.tsx | 170 |
1 files changed, 140 insertions, 30 deletions
diff --git a/language-web/src/main/js/editor/EditorArea.tsx b/language-web/src/main/js/editor/EditorArea.tsx index 531a57c9..678a632d 100644 --- a/language-web/src/main/js/editor/EditorArea.tsx +++ b/language-web/src/main/js/editor/EditorArea.tsx | |||
@@ -1,41 +1,151 @@ | |||
1 | import { Command, EditorView } from '@codemirror/view'; | ||
2 | import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; | ||
3 | import { closeLintPanel, openLintPanel } from '@codemirror/lint'; | ||
1 | import { observer } from 'mobx-react-lite'; | 4 | import { observer } from 'mobx-react-lite'; |
2 | import React, { useRef } from 'react'; | 5 | import React, { |
6 | useCallback, | ||
7 | useEffect, | ||
8 | useRef, | ||
9 | useState, | ||
10 | } from 'react'; | ||
3 | 11 | ||
12 | import { EditorParent } from './EditorParent'; | ||
4 | import { useRootStore } from '../RootStore'; | 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 | } | ||
5 | 72 | ||
6 | export const EditorArea = observer(() => { | 73 | export const EditorArea = observer(() => { |
7 | const { editorStore } = useRootStore(); | 74 | const { editorStore } = useRootStore(); |
8 | const { CodeMirror } = editorStore.chunk || {}; | 75 | const editorParentRef = useRef<HTMLDivElement | null>(null); |
9 | const fallbackTextarea = useRef<HTMLTextAreaElement>(null); | 76 | const [editorViewState, setEditorViewState] = useState<EditorView | null>(null); |
10 | 77 | ||
11 | if (!CodeMirror) { | 78 | const setSearchPanelOpen = usePanel( |
12 | return ( | 79 | 'search', |
13 | <textarea | 80 | editorStore.showSearchPanel, |
14 | value={editorStore.value} | 81 | editorViewState, |
15 | onChange={(e) => editorStore.updateValue(e.target.value)} | 82 | openSearchPanel, |
16 | ref={fallbackTextarea} | 83 | closeSearchPanel, |
17 | className={`problem-fallback-editor cm-s-${editorStore.codeMirrorTheme}`} | 84 | useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]), |
18 | /> | 85 | ); |
19 | ); | 86 | |
20 | } | 87 | const setLintPanelOpen = usePanel( |
21 | 88 | 'panel-lint', | |
22 | const textarea = fallbackTextarea.current; | 89 | editorStore.showLintPanel, |
23 | if (textarea) { | 90 | editorViewState, |
24 | editorStore.setInitialSelection( | 91 | openLintPanel, |
25 | textarea.selectionStart, | 92 | closeLintPanel, |
26 | textarea.selectionEnd, | 93 | useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]), |
27 | document.activeElement === textarea, | 94 | ); |
28 | ); | 95 | |
29 | } | 96 | useEffect(() => { |
97 | if (editorParentRef.current === null) { | ||
98 | // Nothing to clean up. | ||
99 | return () => {}; | ||
100 | } | ||
101 | |||
102 | const editorView = new EditorView({ | ||
103 | state: editorStore.state, | ||
104 | parent: editorParentRef.current, | ||
105 | dispatch: (transaction) => { | ||
106 | editorStore.onTransaction(transaction); | ||
107 | editorView.update([transaction]); | ||
108 | if (editorView.state !== editorStore.state) { | ||
109 | log.error( | ||
110 | 'Failed to synchronize editor state - store state:', | ||
111 | editorStore.state, | ||
112 | 'view state:', | ||
113 | editorView.state, | ||
114 | ); | ||
115 | } | ||
116 | }, | ||
117 | }); | ||
118 | fixCodeMirrorAccessibility(editorView); | ||
119 | setEditorViewState(editorView); | ||
120 | setSearchPanelOpen(false); | ||
121 | setLintPanelOpen(false); | ||
122 | // `dispatch` is bound to the view instance, | ||
123 | // so it does not have to be called as a method. | ||
124 | // eslint-disable-next-line @typescript-eslint/unbound-method | ||
125 | editorStore.updateDispatcher(editorView.dispatch); | ||
126 | log.info('Editor created'); | ||
127 | |||
128 | return () => { | ||
129 | editorStore.updateDispatcher(null); | ||
130 | editorView.destroy(); | ||
131 | log.info('Editor destroyed'); | ||
132 | }; | ||
133 | }, [ | ||
134 | editorParentRef, | ||
135 | editorStore, | ||
136 | setSearchPanelOpen, | ||
137 | setLintPanelOpen, | ||
138 | ]); | ||
30 | 139 | ||
31 | return ( | 140 | return ( |
32 | <CodeMirror | 141 | <EditorParent |
33 | value={editorStore.value} | 142 | className="dark" |
34 | options={editorStore.codeMirrorOptions} | 143 | sx={{ |
35 | editorDidMount={(editor) => editorStore.editorDidMount(editor)} | 144 | '.cm-lineNumbers': editorStore.showLineNumbers ? {} : { |
36 | editorWillUnmount={() => editorStore.editorWillUnmount()} | 145 | display: 'none !important', |
37 | onBeforeChange={(_editor, _data, value) => editorStore.updateValue(value)} | 146 | }, |
38 | onChange={() => editorStore.reportChanged()} | 147 | }} |
148 | ref={editorParentRef} | ||
39 | /> | 149 | /> |
40 | ); | 150 | ); |
41 | }); | 151 | }); |