code-editor.ts ➔ makeMonacoEditor   F
last analyzed

Complexity

Conditions 36

Size

Total Lines 202
Code Lines 147

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 147
dl 0
loc 202
rs 0
c 0
b 0
f 0
cc 36

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like code-editor.ts ➔ makeMonacoEditor often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
/**
2
 * CodeEditor Craft CMS
3
 *
4
 * Provides a code editor field with Twig & Craft API autocomplete
5
 *
6
 * @link      https://nystudio107.com
7
 * @copyright Copyright (c) 2022 nystudio107
8
 */
9
10
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
11
import {editor} from "monaco-editor/esm/vs/editor/editor.api";
12
import {TabFocus} from 'monaco-editor/esm/vs/editor/browser/config/tabFocus.js';
13
import {getCompletionItemsFromEndpoint} from './twig-autocomplete';
14
import {languageIcons} from './language-icons'
15
import {defaultMonacoOptions} from "./default-monaco-options";
16
import EditorOption = editor.EditorOption;
17
18
/**
19
 * @author    nystudio107
20
 * @package   CodeEditor
21
 * @since     1.0.0
22
 */
23
24
declare global {
25
  let __webpack_public_path__: string;
26
  const Craft: Craft;
27
28
  interface Window {
29
    codeEditorBaseAssetsUrl: string;
30
    makeMonacoEditor: MakeMonacoEditorFn;
31
    setMonacoEditorLanguage: SetMonacoEditorLanguageFn;
32
    setMonacoEditorTheme: SetMonacoEditorThemeFn;
33
    monacoEditorInstances: { [key: string]: monaco.editor.IStandaloneCodeEditor };
34
  }
35
}
36
37
const MAX_EDITOR_ROWS = 50;
38
39
// Set the __webpack_public_path__ dynamically, so we can work inside cpresources's hashed dir name
40
// https://stackoverflow.com/questions/39879680/example-of-setting-webpack-public-path-at-runtime
41
if (typeof __webpack_public_path__ === 'undefined' || __webpack_public_path__ === '') {
42
  __webpack_public_path__ = window.codeEditorBaseAssetsUrl;
43
}
44
45
/**
46
 * Create a Monaco Editor instance
47
 *
48
 * @param {string} elementId - The id of the TextArea or Input element to replace with a Monaco editor
49
 * @param {string} fieldType - The field's passed in type, used for autocomplete caching
50
 * @param {monaco.editor.IStandaloneEditorConstructionOptions} monacoOptions - Monaco editor options
51
 * @param {string} codeEditorOptions - JSON encoded string of arbitrary CodeEditorOptions for the field
52
 * @param {string} endpointUrl - The controller action endpoint for generating autocomplete items
53
 */
54
function makeMonacoEditor(elementId: string, fieldType: string, monacoOptions: string, codeEditorOptions: string, endpointUrl: string): monaco.editor.IStandaloneCodeEditor | undefined {
55
  const textArea = <HTMLInputElement | null>document.getElementById(elementId);
56
  const container = document.createElement('div');
57
  const fieldOptions: CodeEditorOptions = JSON.parse(codeEditorOptions);
58
  const placeholderId = elementId + '-monaco-editor-placeholder';
59
  // If we can't find the passed in text area or if there is no parent node, return
60
  if (textArea === null || textArea.parentNode === null) {
61
    return;
62
  }
63
  // See if the `underline-links` class has already been added to the <body> element
64
  const hasUnderlineLinksBodyClass = document.body.classList.contains('underline-links');
65
  // Monaco editor options passed in from the config
66
  const monacoEditorOptions: monaco.editor.IStandaloneEditorConstructionOptions = JSON.parse(monacoOptions);
67
  // Set the scrollbar to hidden in defaultMonacoOptions if this is a single-line field
68
  if ('singleLineEditor' in fieldOptions && fieldOptions.singleLineEditor) {
69
    defaultMonacoOptions.scrollbar = {
70
      vertical: 'hidden',
71
      horizontal: 'auto',
72
      alwaysConsumeMouseWheel: false,
73
      handleMouseWheel: false,
74
    };
75
  }
76
  // Create the model with a unique URI so individual instances can be targeted
77
  let modelUri = monaco.Uri.parse('https://craft-code-editor.com/' + elementId);
78
  // Assign a default language
79
  let monacoEditorLanguage = monacoEditorOptions.language ?? defaultMonacoOptions.language;
80
  // If they are passing in the name of the file, use it for the modelUri, and set the language to undefined,
81
  // so Monaco can auto-detect the language
82
  if ('fileName' in fieldOptions && fieldOptions.fileName) {
83
    modelUri = monaco.Uri.file(fieldOptions.fileName);
84
    monacoEditorLanguage = undefined;
85
  }
86
  monaco.editor.getModel(modelUri)?.dispose();
87
  const textModel = monaco.editor.createModel(textArea.value, monacoEditorLanguage, modelUri);
88
  defaultMonacoOptions.model = textModel;
89
  // Set the editor theme here, so we don't re-apply it later
90
  monacoEditorOptions.theme = getEditorTheme(monacoEditorOptions?.theme);
91
  // Monaco editor defaults, coalesced together
92
  const options: monaco.editor.IStandaloneEditorConstructionOptions = {...defaultMonacoOptions, ...monacoEditorOptions}
93
  // Make a sibling div for the Monaco editor to live in
94
  container.id = elementId + '-monaco-editor';
95
  container.classList.add('monaco-editor', 'craft-code-editor-relative', 'craft-code-editor-box-content', 'monaco-editor-codefield', 'craft-code-editor-h-full');
96
  // Apply any passed in classes to the wrapper div
97
  const wrapperClass = fieldOptions.wrapperClass ?? '';
98
  if (wrapperClass !== '') {
99
    const cl = container.classList;
100
    const classArray = wrapperClass.trim().split(/\s+/);
101
    cl.add(...classArray);
102
  }
103
  // Create an empty div for the icon
104
  const displayLanguageIcon = fieldOptions.displayLanguageIcon ?? true;
105
  if (displayLanguageIcon) {
106
    const icon = document.createElement('div');
107
    icon.id = elementId + '-monaco-language-icon';
108
    container.appendChild(icon);
109
  }
110
  // Handle the placeholder text (if any)
111
  const placeholderText = fieldOptions.placeholderText ?? '';
112
  if (placeholderText !== '') {
113
    const placeholder = document.createElement('div');
114
    placeholder.id = elementId + '-monaco-editor-placeholder';
115
    placeholder.innerHTML = placeholderText;
116
    placeholder.classList.add('monaco-placeholder', 'craft-code-editor-p-2');
117
    container.appendChild(placeholder);
118
  }
119
  textArea.parentNode.insertBefore(container, textArea);
120
  textArea.style.display = 'none';
121
  // Create the Monaco editor
122
  const editor = monaco.editor.create(container, options);
123
  // Make the monaco editor instances available via the monacoEditorInstances global, since Twig macros can't return a value
124
  if (typeof window.monacoEditorInstances === 'undefined') {
125
    window.monacoEditorInstances = {};
126
  }
127
  window.monacoEditorInstances[elementId] = editor;
128
  // When the text is changed in the editor, sync it to the underlying TextArea input
129
  editor.onDidChangeModelContent(() => {
130
    textArea.value = editor.getValue();
131
  });
132
  // Add the language icon (if any)
133
  setMonacoEditorLanguage(editor, options.language, elementId);
134
  // ref: https://github.com/vikyd/vue-monaco-singleline/blob/master/src/monaco-singleline.vue#L150
135
  if ('singleLineEditor' in fieldOptions && fieldOptions.singleLineEditor) {
136
    if (textModel !== null) {
137
      // Remove multiple spaces & tabs
138
      const text = textModel.getValue();
139
      textModel.setValue(text.replace(/\s\s+/g, ' '));
140
      // Handle the Find command
141
      editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
142
      });
143
      // Handle typing the Enter key
144
      editor.addCommand(monaco.KeyCode.Enter, () => {
145
      }, '!suggestWidgetVisible');
146
      // Enable TabFocusMode ref: https://stackoverflow.com/questions/74202202/how-to-programatically-set-tabfocusmode-in-monaco-editor/74598917
147
      editor.onDidFocusEditorWidget(() => {
148
        TabFocus.setTabFocusMode(true);
149
      });
150
      // Handle Paste
151
      editor.onDidPaste(() => {
152
        // multiple rows will be merged to single row
153
        let newContent = '';
154
        const lineCount = textModel.getLineCount();
155
        // remove all line breaks
156
        for (let i = 0; i < lineCount; i += 1) {
157
          newContent += textModel.getLineContent(i + 1);
158
        }
159
        // Remove multiple spaces & tabs
160
        newContent = newContent.replace(/\s\s+/g, ' ');
161
        textModel.setValue(newContent);
162
        editor.setPosition({column: newContent.length + 1, lineNumber: 1});
163
      })
164
    }
165
  }
166
  // Get the autocompletion items if the language is Twig
167
  if (options.language === 'twig') {
168
    getCompletionItemsFromEndpoint(fieldType, codeEditorOptions, endpointUrl);
169
  }
170
  // Custom resizer to always keep the editor full-height, without needing to scroll
171
  let ignoreEvent = false;
172
  const updateHeight = () => {
173
    const width = editor.getLayoutInfo().width;
174
    const lineHeight = editor.getOption(EditorOption.lineHeight);
175
    const maxEditorRows = fieldOptions.maxEditorRows ?? MAX_EDITOR_ROWS;
176
    let contentHeight = editor.getContentHeight();
177
    if (maxEditorRows !== 0) {
178
      contentHeight = Math.min(lineHeight * maxEditorRows, editor.getContentHeight());
179
    }
180
    if (textArea instanceof HTMLTextAreaElement) {
181
      contentHeight = Math.max(textArea.rows * lineHeight, contentHeight)
182
    }
183
    //container.style.width = `${width}px`;
184
    container.style.height = `${contentHeight}px`;
185
    try {
186
      ignoreEvent = true;
187
      editor.layout({width, height: contentHeight});
188
    } finally {
189
      ignoreEvent = false;
190
    }
191
  };
192
  // Handle fixed height editors
193
  let dynamicHeight = true;
194
  if ('fixedHeightEditor' in fieldOptions && fieldOptions.fixedHeightEditor) {
195
    dynamicHeight = false;
196
  }
197
  if (dynamicHeight) {
198
    editor.onDidContentSizeChange(updateHeight);
199
    updateHeight();
200
  }
201
  // Handle the placeholder
202
  if (placeholderText !== '') {
203
    showPlaceholder('#' + placeholderId, editor.getValue());
204
    editor.onDidBlurEditorWidget(() => {
205
      showPlaceholder('#' + placeholderId, editor.getValue());
206
    });
207
    editor.onDidFocusEditorWidget(() => {
208
      hidePlaceholder('#' + placeholderId);
209
    });
210
  }
211
212
  /**
213
   * Show the placeholder text
214
   *
215
   * @param {string} selector - The selector for the placeholder element
216
   * @param {string} value - The editor field's value (the text)
217
   */
218
  function showPlaceholder(selector: string, value: string): void {
219
    if (value === "") {
220
      const elem = <HTMLElement | null>document.querySelector(selector);
221
      if (elem !== null) {
222
        elem.style.display = "initial";
223
      }
224
    }
225
  }
226
227
  /**
228
   * Hide the placeholder text
229
   *
230
   * @param {string} selector - The selector for the placeholder element
231
   */
232
  function hidePlaceholder(selector: string): void {
233
    const elem = <HTMLElement | null>document.querySelector(selector);
234
    if (elem !== null) {
235
      elem.style.display = "none";
236
    }
237
  }
238
239
  // Unless the class `underline-links` was already in the <body> tag, remove it because the Monaco
240
  // editor added it during initialization, and there doesn't appear to be a way to configure the
241
  // `accessibility.underlineLinks` setting via IEditorOptions
242
  // ref: https://github.com/nystudio107/craft-code-editor/issues/16
243
  document.body.classList.toggle('underline-links', hasUnderlineLinksBodyClass);
244
245
  return editor;
246
}
247
248
/**
249
 * Set the language for the Monaco editor instance
250
 *
251
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
252
 * @param {string | undefined} language - the editor language
253
 * @param {string} elementId - the element id used to create the monaco editor from
254
 */
255
function setMonacoEditorLanguage(editor: monaco.editor.IStandaloneCodeEditor, language: string | undefined, elementId: string): void {
256
  const containerId = elementId + '-monaco-editor';
257
  const iconId = elementId + '-monaco-language-icon';
258
  const container = <Element | null>document.querySelector('#' + containerId);
259
  if (container !== null) {
260
    if (typeof language !== "undefined") {
261
      const languageIcon = languageIcons[language] ?? languageIcons['default'] ?? null;
262
      const icon = document.createElement('div');
263
      monaco.editor.setModelLanguage(editor.getModel()!, language);
264
      icon.id = iconId;
265
      // Only add in the icon if one is available
266
      if (languageIcon !== null) {
267
        let message = 'code is supported.';
268
        if (window.hasOwnProperty('Craft')) {
269
          message = Craft.t('codeeditor', message);
270
        }
271
        const languageTitle = language.charAt(0).toUpperCase() + language.slice(1) + ' ' + message;
272
        icon.classList.add('monaco-editor-codefield--icon');
273
        icon.setAttribute('title', languageTitle);
274
        icon.setAttribute('aria-hidden', 'true');
275
        icon.innerHTML = languageIcon;
276
      }
277
      // Replace the icon div
278
      const currentIcon = container.querySelector('#' + iconId);
279
      if (currentIcon) {
280
        container.replaceChild(icon, currentIcon);
281
      }
282
    }
283
  }
284
}
285
286
/**
287
 * Return the editor theme to use, accounting for undefined and 'auto' as potential parameters
288
 *
289
 * @param {string | undefined} theme - the editor theme
290
 */
291
function getEditorTheme(theme: string | undefined): string {
292
  let editorTheme = theme ?? 'vs';
293
  if (editorTheme === 'auto') {
294
    const mediaQueryObj = window.matchMedia('(prefers-color-scheme: dark)');
295
    editorTheme = mediaQueryObj.matches ? 'vs-dark' : 'vs';
296
  }
297
298
  return editorTheme;
299
}
300
301
/**
302
 * Set the theme for the Monaco editor instance
303
 *
304
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
305
 * @param {string | undefined} theme - the editor theme
306
 */
307
function setMonacoEditorTheme(editor: monaco.editor.IStandaloneCodeEditor, theme: string | undefined): void {
308
  const editorTheme = getEditorTheme(theme);
309
  // @ts-ignore
310
  const currentTheme = editor._themeService?._theme?.themeName ?? null;
311
  if (currentTheme !== editorTheme) {
312
    editor.updateOptions({theme: editorTheme});
313
  }
314
}
315
316
// Make the functions globally available
317
// For whatever reason, setting `globalAPI: true` in the config no longer exposes the global `monaco` object,
318
// so we just do it ourselves manually here for now
319
// @ts-ignore
320
window.monaco = monaco;
321
window.makeMonacoEditor = makeMonacoEditor;
322
window.setMonacoEditorLanguage = setMonacoEditorLanguage;
323
window.setMonacoEditorTheme = setMonacoEditorTheme;
324
325
export {makeMonacoEditor, setMonacoEditorLanguage, setMonacoEditorTheme};
326