Passed
Push — develop ( 71429f...529635 )
by Andrew
05:47
created

src/web/assets/src/js/code-editor.ts   C

Complexity

Total Complexity 54
Complexity/F 9

Size

Lines of Code 312
Function Count 6

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 54
eloc 210
mnd 48
bc 48
fnc 6
dl 0
loc 312
rs 6.4799
bpm 8
cpm 9
noi 0
c 0
b 0
f 0

4 Functions

Rating   Name   Duplication   Size   Complexity  
C code-editor.ts ➔ setMonacoEditorLanguage 0 34 10
B code-editor.ts ➔ setMonacoEditorTheme 0 13 6
F code-editor.ts ➔ makeMonacoEditor 0 192 33
A code-editor.ts ➔ getEditorTheme 0 14 5

How to fix   Complexity   

Complexity

Complex classes like src/web/assets/src/js/code-editor.ts 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
  // Only create the model if none exist already
85
  if (monaco.editor.getModels().length === 0) {
86
    defaultMonacoOptions.model = monaco.editor.createModel(textArea.value, monacoEditorLanguage, modelUri);
87
  }
88
  // Set the editor theme here, so we don't re-apply it later
89
  monacoEditorOptions.theme = getEditorTheme(monacoEditorOptions?.theme);
90
  // Monaco editor defaults, coalesced together
91
  const options: monaco.editor.IStandaloneEditorConstructionOptions = {...defaultMonacoOptions, ...monacoEditorOptions}
92
  // Make a sibling div for the Monaco editor to live in
93
  container.id = elementId + '-monaco-editor';
94
  container.classList.add('monaco-editor', 'relative', 'box-content', 'monaco-editor-codefield', 'h-full');
95
  // Apply any passed in classes to the wrapper div
96
  const wrapperClass = fieldOptions.wrapperClass ?? '';
97
  if (wrapperClass !== '') {
98
    const cl = container.classList;
99
    const classArray = wrapperClass.trim().split(/\s+/);
100
    cl.add(...classArray);
101
  }
102
  // Create an empty div for the icon
103
  const displayLanguageIcon = fieldOptions.displayLanguageIcon ?? true;
104
  if (displayLanguageIcon) {
105
    const icon = document.createElement('div');
106
    icon.id = elementId + '-monaco-language-icon';
107
    container.appendChild(icon);
108
  }
109
  // Handle the placeholder text (if any)
110
  const placeholderText = fieldOptions.placeholderText ?? '';
111
  if (placeholderText !== '') {
112
    const placeholder = document.createElement('div');
113
    placeholder.id = elementId + '-monaco-editor-placeholder';
114
    placeholder.innerHTML = placeholderText;
115
    placeholder.classList.add('monaco-placeholder', 'p-2');
116
    container.appendChild(placeholder);
117
  }
118
  textArea.parentNode.insertBefore(container, textArea);
119
  textArea.style.display = 'none';
120
  // Create the Monaco editor
121
  const editor = monaco.editor.create(container, options);
122
  const textModel: monaco.editor.ITextModel | null = editor.getModel();
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
    let contentHeight = Math.min(lineHeight * MAX_EDITOR_ROWS, editor.getContentHeight());
176
    if (textArea instanceof HTMLTextAreaElement) {
177
      contentHeight = Math.max(textArea.rows * lineHeight, contentHeight)
178
    }
179
    //container.style.width = `${width}px`;
180
    container.style.height = `${contentHeight}px`;
181
    try {
182
      ignoreEvent = true;
183
      editor.layout({width, height: contentHeight});
184
    } finally {
185
      ignoreEvent = false;
186
    }
187
  };
188
  // Handle fixed height editors
189
  let dynamicHeight = true;
190
  if ('fixedHeightEditor' in fieldOptions && fieldOptions.fixedHeightEditor) {
191
    dynamicHeight = false;
192
  }
193
  if (dynamicHeight) {
194
    editor.onDidContentSizeChange(updateHeight);
195
    updateHeight();
196
  }
197
  // Handle the placeholder
198
  if (placeholderText !== '') {
199
    showPlaceholder('#' + placeholderId, editor.getValue());
200
    editor.onDidBlurEditorWidget(() => {
201
      showPlaceholder('#' + placeholderId, editor.getValue());
202
    });
203
    editor.onDidFocusEditorWidget(() => {
204
      hidePlaceholder('#' + placeholderId);
205
    });
206
  }
207
208
  /**
209
   * Show the placeholder text
210
   *
211
   * @param {string} selector - The selector for the placeholder element
212
   * @param {string} value - The editor field's value (the text)
213
   */
214
  function showPlaceholder(selector: string, value: string): void {
215
    if (value === "") {
216
      const elem = <HTMLElement | null>document.querySelector(selector);
217
      if (elem !== null) {
218
        elem.style.display = "initial";
219
      }
220
    }
221
  }
222
223
  /**
224
   * Hide the placeholder text
225
   *
226
   * @param {string} selector - The selector for the placeholder element
227
   */
228
  function hidePlaceholder(selector: string): void {
229
    const elem = <HTMLElement | null>document.querySelector(selector);
230
    if (elem !== null) {
231
      elem.style.display = "none";
232
    }
233
  }
234
235
  return editor;
236
}
237
238
/**
239
 * Set the language for the Monaco editor instance
240
 *
241
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
242
 * @param {string | undefined} language - the editor language
243
 * @param {string} elementId - the element id used to create the monaco editor from
244
 */
245
function setMonacoEditorLanguage(editor: monaco.editor.IStandaloneCodeEditor, language: string | undefined, elementId: string): void {
246
  const containerId = elementId + '-monaco-editor';
247
  const iconId = elementId + '-monaco-language-icon';
248
  const container = <Element | null>document.querySelector('#' + containerId);
249
  if (container !== null) {
250
    if (typeof language !== "undefined") {
251
      const languageIcon = languageIcons[language] ?? languageIcons['default'] ?? null;
252
      const icon = document.createElement('div');
253
      monaco.editor.setModelLanguage(editor.getModel()!, language);
254
      icon.id = iconId;
255
      // Only add in the icon if one is available
256
      if (languageIcon !== null) {
257
        let message = 'code is supported.';
258
        if (window.hasOwnProperty('Craft')) {
259
          message = Craft.t('codeeditor', message);
260
        }
261
        const languageTitle = language.charAt(0).toUpperCase() + language.slice(1) + ' ' + message;
262
        icon.classList.add('monaco-editor-codefield--icon');
263
        icon.setAttribute('title', languageTitle);
264
        icon.setAttribute('aria-hidden', 'true');
265
        icon.innerHTML = languageIcon;
266
      }
267
      // Replace the icon div
268
      const currentIcon = container.querySelector('#' + iconId);
269
      if (currentIcon) {
270
        container.replaceChild(icon, currentIcon);
271
      }
272
    }
273
  }
274
}
275
276
/**
277
 * Return the editor theme to use, accounting for undefined and 'auto' as potential parameters
278
 *
279
 * @param {string | undefined} theme - the editor theme
280
 */
281
function getEditorTheme(theme: string | undefined): string {
282
  let editorTheme = theme ?? 'vs';
283
  if (editorTheme === 'auto') {
284
    const mediaQueryObj = window.matchMedia('(prefers-color-scheme: dark)');
285
    editorTheme = mediaQueryObj.matches ? 'vs-dark' : 'vs';
286
  }
287
288
  return editorTheme;
289
}
290
291
/**
292
 * Set the theme for the Monaco editor instance
293
 *
294
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
295
 * @param {string | undefined} theme - the editor theme
296
 */
297
function setMonacoEditorTheme(editor: monaco.editor.IStandaloneCodeEditor, theme: string | undefined): void {
298
  const editorTheme = getEditorTheme(theme);
299
  // @ts-ignore
300
  const currentTheme = editor._themeService?._theme?.themeName ?? null;
301
  if (currentTheme !== editorTheme) {
302
    editor.updateOptions({theme: editorTheme});
303
  }
304
}
305
306
// Make the functions globally available
307
window.makeMonacoEditor = makeMonacoEditor;
308
window.setMonacoEditorLanguage = setMonacoEditorLanguage;
309
window.setMonacoEditorTheme = setMonacoEditorTheme;
310
311
export {makeMonacoEditor, setMonacoEditorLanguage, setMonacoEditorTheme};
312