code-editor.ts ➔ makeMonacoEditor   F
last analyzed

Complexity

Conditions 36

Size

Total Lines 194
Code Lines 143

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 143
dl 0
loc 194
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
  // Monaco editor options passed in from the config
64
  const monacoEditorOptions: monaco.editor.IStandaloneEditorConstructionOptions = JSON.parse(monacoOptions);
65
  // Set the scrollbar to hidden in defaultMonacoOptions if this is a single-line field
66
  if ('singleLineEditor' in fieldOptions && fieldOptions.singleLineEditor) {
67
    defaultMonacoOptions.scrollbar = {
68
      vertical: 'hidden',
69
      horizontal: 'auto',
70
      alwaysConsumeMouseWheel: false,
71
      handleMouseWheel: false,
72
    };
73
  }
74
  // Create the model with a unique URI so individual instances can be targeted
75
  let modelUri = monaco.Uri.parse('https://craft-code-editor.com/' + elementId);
76
  // Assign a default language
77
  let monacoEditorLanguage = monacoEditorOptions.language ?? defaultMonacoOptions.language;
78
  // If they are passing in the name of the file, use it for the modelUri, and set the language to undefined,
79
  // so Monaco can auto-detect the language
80
  if ('fileName' in fieldOptions && fieldOptions.fileName) {
81
    modelUri = monaco.Uri.file(fieldOptions.fileName);
82
    monacoEditorLanguage = undefined;
83
  }
84
  monaco.editor.getModel(modelUri)?.dispose();
85
  const textModel = monaco.editor.createModel(textArea.value, monacoEditorLanguage, modelUri);
86
  defaultMonacoOptions.model = textModel;
87
  // Set the editor theme here, so we don't re-apply it later
88
  monacoEditorOptions.theme = getEditorTheme(monacoEditorOptions?.theme);
89
  // Monaco editor defaults, coalesced together
90
  const options: monaco.editor.IStandaloneEditorConstructionOptions = {...defaultMonacoOptions, ...monacoEditorOptions}
91
  // Make a sibling div for the Monaco editor to live in
92
  container.id = elementId + '-monaco-editor';
93
  container.classList.add('monaco-editor', 'craft-code-editor-relative', 'craft-code-editor-box-content', 'monaco-editor-codefield', 'craft-code-editor-h-full');
94
  // Apply any passed in classes to the wrapper div
95
  const wrapperClass = fieldOptions.wrapperClass ?? '';
96
  if (wrapperClass !== '') {
97
    const cl = container.classList;
98
    const classArray = wrapperClass.trim().split(/\s+/);
99
    cl.add(...classArray);
100
  }
101
  // Create an empty div for the icon
102
  const displayLanguageIcon = fieldOptions.displayLanguageIcon ?? true;
103
  if (displayLanguageIcon) {
104
    const icon = document.createElement('div');
105
    icon.id = elementId + '-monaco-language-icon';
106
    container.appendChild(icon);
107
  }
108
  // Handle the placeholder text (if any)
109
  const placeholderText = fieldOptions.placeholderText ?? '';
110
  if (placeholderText !== '') {
111
    const placeholder = document.createElement('div');
112
    placeholder.id = elementId + '-monaco-editor-placeholder';
113
    placeholder.innerHTML = placeholderText;
114
    placeholder.classList.add('monaco-placeholder', 'craft-code-editor-p-2');
115
    container.appendChild(placeholder);
116
  }
117
  textArea.parentNode.insertBefore(container, textArea);
118
  textArea.style.display = 'none';
119
  // Create the Monaco editor
120
  const editor = monaco.editor.create(container, options);
121
  // Make the monaco editor instances available via the monacoEditorInstances global, since Twig macros can't return a value
122
  if (typeof window.monacoEditorInstances === 'undefined') {
123
    window.monacoEditorInstances = {};
124
  }
125
  window.monacoEditorInstances[elementId] = editor;
126
  // When the text is changed in the editor, sync it to the underlying TextArea input
127
  editor.onDidChangeModelContent(() => {
128
    textArea.value = editor.getValue();
129
  });
130
  // Add the language icon (if any)
131
  setMonacoEditorLanguage(editor, options.language, elementId);
132
  // ref: https://github.com/vikyd/vue-monaco-singleline/blob/master/src/monaco-singleline.vue#L150
133
  if ('singleLineEditor' in fieldOptions && fieldOptions.singleLineEditor) {
134
    if (textModel !== null) {
135
      // Remove multiple spaces & tabs
136
      const text = textModel.getValue();
137
      textModel.setValue(text.replace(/\s\s+/g, ' '));
138
      // Handle the Find command
139
      editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
140
      });
141
      // Handle typing the Enter key
142
      editor.addCommand(monaco.KeyCode.Enter, () => {
143
      }, '!suggestWidgetVisible');
144
      // Enable TabFocusMode ref: https://stackoverflow.com/questions/74202202/how-to-programatically-set-tabfocusmode-in-monaco-editor/74598917
145
      editor.onDidFocusEditorWidget(() => {
146
        TabFocus.setTabFocusMode(true);
147
      });
148
      // Handle Paste
149
      editor.onDidPaste(() => {
150
        // multiple rows will be merged to single row
151
        let newContent = '';
152
        const lineCount = textModel.getLineCount();
153
        // remove all line breaks
154
        for (let i = 0; i < lineCount; i += 1) {
155
          newContent += textModel.getLineContent(i + 1);
156
        }
157
        // Remove multiple spaces & tabs
158
        newContent = newContent.replace(/\s\s+/g, ' ');
159
        textModel.setValue(newContent);
160
        editor.setPosition({column: newContent.length + 1, lineNumber: 1});
161
      })
162
    }
163
  }
164
  // Get the autocompletion items if the language is Twig
165
  if (options.language === 'twig') {
166
    getCompletionItemsFromEndpoint(fieldType, codeEditorOptions, endpointUrl);
167
  }
168
  // Custom resizer to always keep the editor full-height, without needing to scroll
169
  let ignoreEvent = false;
170
  const updateHeight = () => {
171
    const width = editor.getLayoutInfo().width;
172
    const lineHeight = editor.getOption(EditorOption.lineHeight);
173
    const maxEditorRows = fieldOptions.maxEditorRows ?? MAX_EDITOR_ROWS;
174
    let contentHeight = editor.getContentHeight();
175
    if (maxEditorRows !== 0) {
176
      contentHeight = Math.min(lineHeight * maxEditorRows, editor.getContentHeight());
177
    }
178
    if (textArea instanceof HTMLTextAreaElement) {
179
      contentHeight = Math.max(textArea.rows * lineHeight, contentHeight)
180
    }
181
    //container.style.width = `${width}px`;
182
    container.style.height = `${contentHeight}px`;
183
    try {
184
      ignoreEvent = true;
185
      editor.layout({width, height: contentHeight});
186
    } finally {
187
      ignoreEvent = false;
188
    }
189
  };
190
  // Handle fixed height editors
191
  let dynamicHeight = true;
192
  if ('fixedHeightEditor' in fieldOptions && fieldOptions.fixedHeightEditor) {
193
    dynamicHeight = false;
194
  }
195
  if (dynamicHeight) {
196
    editor.onDidContentSizeChange(updateHeight);
197
    updateHeight();
198
  }
199
  // Handle the placeholder
200
  if (placeholderText !== '') {
201
    showPlaceholder('#' + placeholderId, editor.getValue());
202
    editor.onDidBlurEditorWidget(() => {
203
      showPlaceholder('#' + placeholderId, editor.getValue());
204
    });
205
    editor.onDidFocusEditorWidget(() => {
206
      hidePlaceholder('#' + placeholderId);
207
    });
208
  }
209
210
  /**
211
   * Show the placeholder text
212
   *
213
   * @param {string} selector - The selector for the placeholder element
214
   * @param {string} value - The editor field's value (the text)
215
   */
216
  function showPlaceholder(selector: string, value: string): void {
217
    if (value === "") {
218
      const elem = <HTMLElement | null>document.querySelector(selector);
219
      if (elem !== null) {
220
        elem.style.display = "initial";
221
      }
222
    }
223
  }
224
225
  /**
226
   * Hide the placeholder text
227
   *
228
   * @param {string} selector - The selector for the placeholder element
229
   */
230
  function hidePlaceholder(selector: string): void {
231
    const elem = <HTMLElement | null>document.querySelector(selector);
232
    if (elem !== null) {
233
      elem.style.display = "none";
234
    }
235
  }
236
237
  return editor;
238
}
239
240
/**
241
 * Set the language for the Monaco editor instance
242
 *
243
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
244
 * @param {string | undefined} language - the editor language
245
 * @param {string} elementId - the element id used to create the monaco editor from
246
 */
247
function setMonacoEditorLanguage(editor: monaco.editor.IStandaloneCodeEditor, language: string | undefined, elementId: string): void {
248
  const containerId = elementId + '-monaco-editor';
249
  const iconId = elementId + '-monaco-language-icon';
250
  const container = <Element | null>document.querySelector('#' + containerId);
251
  if (container !== null) {
252
    if (typeof language !== "undefined") {
253
      const languageIcon = languageIcons[language] ?? languageIcons['default'] ?? null;
254
      const icon = document.createElement('div');
255
      monaco.editor.setModelLanguage(editor.getModel()!, language);
256
      icon.id = iconId;
257
      // Only add in the icon if one is available
258
      if (languageIcon !== null) {
259
        let message = 'code is supported.';
260
        if (window.hasOwnProperty('Craft')) {
261
          message = Craft.t('codeeditor', message);
262
        }
263
        const languageTitle = language.charAt(0).toUpperCase() + language.slice(1) + ' ' + message;
264
        icon.classList.add('monaco-editor-codefield--icon');
265
        icon.setAttribute('title', languageTitle);
266
        icon.setAttribute('aria-hidden', 'true');
267
        icon.innerHTML = languageIcon;
268
      }
269
      // Replace the icon div
270
      const currentIcon = container.querySelector('#' + iconId);
271
      if (currentIcon) {
272
        container.replaceChild(icon, currentIcon);
273
      }
274
    }
275
  }
276
}
277
278
/**
279
 * Return the editor theme to use, accounting for undefined and 'auto' as potential parameters
280
 *
281
 * @param {string | undefined} theme - the editor theme
282
 */
283
function getEditorTheme(theme: string | undefined): string {
284
  let editorTheme = theme ?? 'vs';
285
  if (editorTheme === 'auto') {
286
    const mediaQueryObj = window.matchMedia('(prefers-color-scheme: dark)');
287
    editorTheme = mediaQueryObj.matches ? 'vs-dark' : 'vs';
288
  }
289
290
  return editorTheme;
291
}
292
293
/**
294
 * Set the theme for the Monaco editor instance
295
 *
296
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
297
 * @param {string | undefined} theme - the editor theme
298
 */
299
function setMonacoEditorTheme(editor: monaco.editor.IStandaloneCodeEditor, theme: string | undefined): void {
300
  const editorTheme = getEditorTheme(theme);
301
  // @ts-ignore
302
  const currentTheme = editor._themeService?._theme?.themeName ?? null;
303
  if (currentTheme !== editorTheme) {
304
    editor.updateOptions({theme: editorTheme});
305
  }
306
}
307
308
// Make the functions globally available
309
// For whatever reason, setting `globalAPI: true` in the config no longer exposes the global `monaco` object,
310
// so we just do it ourselves manually here for now
311
// @ts-ignore
312
window.monaco = monaco;
313
window.makeMonacoEditor = makeMonacoEditor;
314
window.setMonacoEditorLanguage = setMonacoEditorLanguage;
315
window.setMonacoEditorTheme = setMonacoEditorTheme;
316
317
export {makeMonacoEditor, setMonacoEditorLanguage, setMonacoEditorTheme};
318