Passed
Push — develop ( bec2dc...e96340 )
by Andrew
05:45
created

code-editor.ts ➔ makeMonacoEditor   F

Complexity

Conditions 27

Size

Total Lines 206
Code Lines 140

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 140
dl 0
loc 206
rs 0
c 0
b 0
f 0
cc 27

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
 * Twigfield Craft CMS
3
 *
4
 * Provides a twig editor field with Twig & Craft API autocomplete
5
 *
6
 * @link      https://nystudio107.com
7
 * @copyright Copyright (c) 2022 nystudio107
8
 */
9
10
/**
11
 * @author    nystudio107
12
 * @package   Twigfield
13
 * @since     1.0.0
14
 */
15
16
declare global {
17
  let __webpack_public_path__: string;
18
  const Craft: Craft;
19
20
  interface Window {
21
    codeEditorBaseAssetsUrl: string;
22
    makeMonacoEditor: MakeMonacoEditorFunction;
23
  }
24
}
25
26
// Set the __webpack_public_path__ dynamically so we can work inside of cpresources's hashed dir name
27
// https://stackoverflow.com/questions/39879680/example-of-setting-webpack-public-path-at-runtime
28
if (typeof __webpack_public_path__ === 'undefined' || __webpack_public_path__ === '') {
29
  __webpack_public_path__ = window.codeEditorBaseAssetsUrl;
30
}
31
32
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
33
import {getCompletionItemsFromEndpoint} from './autocomplete';
34
import {languageIcons} from './language-icons'
35
import {defaultMonacoEditorOptions} from './default-monaco-editor-options'
36
37
/**
38
 * Create a Monaco Editor instance
39
 *
40
 * @param {string} elementId - The id of the TextArea or Input element to replace with a Monaco editor
41
 * @param {string} fieldType - The field's passed in type, used for autocomplete caching
42
 * @param {string} wrapperClass - Classes that should be added to the field's wrapper <div>
43
 * @param {IStandaloneEditorConstructionOptions} editorOptions - Monaco editor options
44
 * @param {string} codefieldOptions - JSON encoded string of arbitrary CodefieldOptions for the field
45
 * @param {string} endpointUrl - The controller action endpoint for generating autocomplete items
46
 * @param {string} placeholderText - Placeholder text to use for the field
47
 */
48
function makeMonacoEditor(elementId: string, fieldType: string, wrapperClass: string, editorOptions: string, codefieldOptions: string, endpointUrl: string, placeholderText = ''): monaco.editor.IStandaloneCodeEditor | undefined {
49
  const textArea = <HTMLInputElement>document.getElementById(elementId);
50
  const container = document.createElement('div');
51
  const fieldOptions: CodefieldOptions = JSON.parse(codefieldOptions);
52
  const placeholderId = elementId + '-monaco-editor-placeholder';
53
  // If we can't find the passed in text area or if there is no parent node, return
54
  if (textArea === null || textArea.parentNode === null) {
55
    return;
56
  }
57
  // Monaco editor defaults, coalesced together
58
  const monacoEditorOptions: monaco.editor.IStandaloneEditorConstructionOptions = JSON.parse(editorOptions);
59
  const options: monaco.editor.IStandaloneEditorConstructionOptions = {...defaultMonacoEditorOptions, ...monacoEditorOptions, ...{value: textArea.value}}
60
  // Make a sibling div for the Monaco editor to live in
61
  container.id = elementId + '-monaco-editor';
62
  container.classList.add('relative', 'box-content', 'monaco-editor-codefield', 'h-full');
63
  // Add the icon in, if there is one
64
  const iconHtml = typeof options.language === "undefined" ? null : languageIcons[options.language];
65
  if (iconHtml) {
66
    const icon = document.createElement('div');
67
    icon.classList.add('monaco-editor-codefield--icon');
68
    icon.setAttribute('title', Craft.t('twigfield', 'Twig code is supported.'));
69
    icon.setAttribute('aria-hidden', 'true');
70
    icon.innerHTML = iconHtml;
71
    container.appendChild(icon);
72
  }
73
  // Apply any passed in classes to the wrapper div
74
  if (wrapperClass !== '') {
75
    const cl = container.classList;
76
    const classArray = wrapperClass.trim().split(/\s+/);
77
    cl.add(...classArray);
78
  }
79
  // Handle the placeholder text (if any)
80
  if (placeholderText !== '') {
81
    const placeholder = document.createElement('div');
82
    placeholder.id = elementId + '-monaco-editor-placeholder';
83
    placeholder.innerHTML = placeholderText;
84
    placeholder.classList.add('monaco-placeholder', 'p-2');
85
    container.appendChild(placeholder);
86
  }
87
  textArea.parentNode.insertBefore(container, textArea);
88
  textArea.style.display = 'none';
89
  // Create the Monaco editor
90
  const editor = monaco.editor.create(container, options);
91
  // When the text is changed in the editor, sync it to the underlying TextArea input
92
  editor.onDidChangeModelContent(() => {
93
    textArea.value = editor.getValue();
94
  });
95
  // ref: https://github.com/vikyd/vue-monaco-singleline/blob/master/src/monaco-singleline.vue#L150
96
  if ('singleLineEditor' in fieldOptions && fieldOptions.singleLineEditor) {
97
    const textModel: monaco.editor.ITextModel | null = editor.getModel();
98
    if (textModel !== null) {
99
      // Remove multiple spaces & tabs
100
      const text = textModel.getValue();
101
      textModel.setValue(text.replace(/\s\s+/g, ' '));
102
      // Handle the Find command
103
      editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
104
      });
105
      // Handle typing the Enter key
106
      editor.addCommand(monaco.KeyCode.Enter, () => {
107
      }, '!suggestWidgetVisible');
108
      // Handle typing the Tab key
109
      editor.addCommand(monaco.KeyCode.Tab, () => {
110
        focusNextElement();
111
      });
112
      editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Tab, () => {
113
        focusPrevElement();
114
      });
115
      // Handle Paste
116
      editor.onDidPaste(() => {
117
        // multiple rows will be merged to single row
118
        let newContent = '';
119
        const lineCount = textModel.getLineCount();
120
        // remove all line breaks
121
        for (let i = 0; i < lineCount; i += 1) {
122
          newContent += textModel.getLineContent(i + 1);
123
        }
124
        // Remove multiple spaces & tabs
125
        newContent = newContent.replace(/\s\s+/g, ' ');
126
        textModel.setValue(newContent);
127
        editor.setPosition({column: newContent.length + 1, lineNumber: 1});
128
      })
129
    }
130
  }
131
  // Get the autocompletion items
132
  getCompletionItemsFromEndpoint(fieldType, codefieldOptions, endpointUrl);
133
  // Custom resizer to always keep the editor full-height, without needing to scroll
134
  let ignoreEvent = false;
135
  const updateHeight = () => {
136
    const width = editor.getLayoutInfo().width;
137
    const contentHeight = Math.min(1000, editor.getContentHeight());
138
    //container.style.width = `${width}px`;
139
    container.style.height = `${contentHeight}px`;
140
    try {
141
      ignoreEvent = true;
142
      editor.layout({width, height: contentHeight});
143
    } finally {
144
      ignoreEvent = false;
145
    }
146
  };
147
  editor.onDidContentSizeChange(updateHeight);
148
  updateHeight();
149
  // Handle the placeholder
150
  if (placeholderText !== '') {
151
    showPlaceholder('#' + placeholderId, editor.getValue());
152
    editor.onDidBlurEditorWidget(() => {
153
      showPlaceholder('#' + placeholderId, editor.getValue());
154
    });
155
    editor.onDidFocusEditorWidget(() => {
156
      hidePlaceholder('#' + placeholderId);
157
    });
158
  }
159
160
  /**
161
   * Move the focus to the next element
162
   */
163
  function focusNextElement(): void {
164
    const focusable = getFocusableElements();
165
    if (document.activeElement instanceof HTMLFormElement) {
166
      const index = focusable.indexOf(document.activeElement);
167
      if (index > -1) {
168
        const nextElement = focusable[index + 1] || focusable[0];
169
        nextElement.focus();
170
      }
171
    }
172
  }
173
174
  /**
175
   * Move the focus to the previous element
176
   */
177
  function focusPrevElement(): void {
178
    const focusable = getFocusableElements();
179
    if (document.activeElement instanceof HTMLFormElement) {
180
      const index = focusable.indexOf(document.activeElement);
181
      if (index > -1) {
182
        const prevElement = focusable[index - 1] || focusable[focusable.length];
183
        prevElement.focus();
184
      }
185
    }
186
  }
187
188
  /**
189
   * Get the focusable elements in the current form
190
   *
191
   * @returns {Array<HTMLElement>} - An array of HTMLElements that can be focusable
192
   */
193
  function getFocusableElements(): Array<HTMLElement> {
194
    let focusable: Array<HTMLElement> = [];
195
    // add all elements we want to include in our selection
196
    const focusableElements = 'a:not([disabled]), button:not([disabled]), select:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])';
197
    if (document.activeElement instanceof HTMLFormElement) {
198
      const activeElement: HTMLFormElement = document.activeElement;
199
      if (activeElement && activeElement.form) {
200
        focusable = Array.prototype.filter.call(activeElement.form.querySelectorAll(focusableElements),
201
          function (element) {
202
            if (element instanceof HTMLElement) {
203
              //check for visibility while always include the current activeElement
204
              return element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement
205
            }
206
            return false;
207
          });
208
      }
209
    }
210
211
    return focusable;
212
  }
213
214
  /**
215
   * Show the placeholder text
216
   *
217
   * @param {string} selector - The selector for the placeholder element
218
   * @param {string} value - The editor field's value (the text)
219
   */
220
  function showPlaceholder(selector: string, value: string): void {
221
    if (value === "") {
222
      const elem = <HTMLElement>document.querySelector(selector);
223
      if (elem !== null) {
224
        elem.style.display = "initial";
225
      }
226
    }
227
  }
228
229
  /**
230
   * Hide the placeholder text
231
   *
232
   * @param {string} selector - The selector for the placeholder element
233
   */
234
  function hidePlaceholder(selector: string): void {
235
    const elem = <HTMLElement>document.querySelector(selector);
236
    if (elem !== null) {
237
      elem.style.display = "none";
238
    }
239
  }
240
241
  return editor;
242
}
243
244
// Make the function globally available
245
window.makeMonacoEditor = makeMonacoEditor;
246
247
export default makeMonacoEditor;
248
249