Passed
Push — develop ( 3a427c...cef048 )
by Andrew
05:28
created

code-editor.ts ➔ setMonacoEditorLanguage   C

Complexity

Conditions 9

Size

Total Lines 30
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 22
dl 0
loc 30
c 0
b 0
f 0
rs 6.6666
cc 9
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
/**
11
 * @author    nystudio107
12
 * @package   CodeEditor
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: MakeMonacoEditorFn;
23
    setMonacoEditorLanguage: SetMonacoEditorLanguageFn;
24
    setMonacoEditorTheme: SetMonacoEditorThemeFn;
25
    monacoEditorInstances: { [key: string]: monaco.editor.IStandaloneCodeEditor };
26
  }
27
}
28
29
// Set the __webpack_public_path__ dynamically, so we can work inside cpresources's hashed dir name
30
// https://stackoverflow.com/questions/39879680/example-of-setting-webpack-public-path-at-runtime
31
if (typeof __webpack_public_path__ === 'undefined' || __webpack_public_path__ === '') {
32
  __webpack_public_path__ = window.codeEditorBaseAssetsUrl;
33
}
34
35
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
36
import {getCompletionItemsFromEndpoint} from './twig-autocomplete';
37
import {languageIcons} from './language-icons'
38
import {defaultMonacoOptions} from "./default-monaco-options";
39
40
/**
41
 * Create a Monaco Editor instance
42
 *
43
 * @param {string} elementId - The id of the TextArea or Input element to replace with a Monaco editor
44
 * @param {string} fieldType - The field's passed in type, used for autocomplete caching
45
 * @param {monaco.editor.IStandaloneEditorConstructionOptions} monacoOptions - Monaco editor options
46
 * @param {string} codeEditorOptions - JSON encoded string of arbitrary CodeEditorOptions for the field
47
 * @param {string} endpointUrl - The controller action endpoint for generating autocomplete items
48
 */
49
function makeMonacoEditor(elementId: string, fieldType: string, monacoOptions: string, codeEditorOptions: string, endpointUrl: string): monaco.editor.IStandaloneCodeEditor | undefined {
50
  const textArea = <HTMLInputElement>document.getElementById(elementId);
51
  const container = document.createElement('div');
52
  const fieldOptions: CodeEditorOptions = JSON.parse(codeEditorOptions);
53
  const placeholderId = elementId + '-monaco-editor-placeholder';
54
  // If we can't find the passed in text area or if there is no parent node, return
55
  if (textArea === null || textArea.parentNode === null) {
56
    return;
57
  }
58
  // Monaco editor defaults, coalesced together
59
  const monacoEditorOptions: monaco.editor.IStandaloneEditorConstructionOptions = JSON.parse(monacoOptions);
60
  const options: monaco.editor.IStandaloneEditorConstructionOptions = {...defaultMonacoOptions, ...monacoEditorOptions, ...{value: textArea.value}}
61
  // Make a sibling div for the Monaco editor to live in
62
  container.id = elementId + '-monaco-editor';
63
  container.classList.add('monaco-editor', 'relative', 'box-content', 'monaco-editor-codefield', 'h-full');
64
  // Apply any passed in classes to the wrapper div
65
  const wrapperClass = fieldOptions.wrapperClass ?? '';
66
  if (wrapperClass !== '') {
67
    const cl = container.classList;
68
    const classArray = wrapperClass.trim().split(/\s+/);
69
    cl.add(...classArray);
70
  }
71
  // Create an empty div for the icon
72
  const displayLanguageIcon = fieldOptions.displayLanguageIcon ?? true;
73
  if (displayLanguageIcon) {
74
    const icon = document.createElement('div');
75
    icon.id = elementId + '-monaco-language-icon';
76
    container.appendChild(icon);
77
  }
78
  // Handle the placeholder text (if any)
79
  const placeholderText = fieldOptions.placeholderText ?? '';
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
  // Make the monaco editor instances available via the monacoEditorInstances global, since Twig macros can't return a value
92
  if (typeof window.monacoEditorInstances === 'undefined') {
93
    window.monacoEditorInstances = {};
94
  }
95
  window.monacoEditorInstances[elementId] = editor;
96
  // When the text is changed in the editor, sync it to the underlying TextArea input
97
  editor.onDidChangeModelContent(() => {
98
    textArea.value = editor.getValue();
99
  });
100
  // Add the language icon (if any)
101
  setMonacoEditorLanguage(editor, options.language, elementId);
102
  // Set the editor theme
103
  setMonacoEditorTheme(editor, options.theme);
104
  // ref: https://github.com/vikyd/vue-monaco-singleline/blob/master/src/monaco-singleline.vue#L150
105
  if ('singleLineEditor' in fieldOptions && fieldOptions.singleLineEditor) {
106
    const textModel: monaco.editor.ITextModel | null = editor.getModel();
107
    if (textModel !== null) {
108
      // Remove multiple spaces & tabs
109
      const text = textModel.getValue();
110
      textModel.setValue(text.replace(/\s\s+/g, ' '));
111
      // Handle the Find command
112
      editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
113
      });
114
      // Handle typing the Enter key
115
      editor.addCommand(monaco.KeyCode.Enter, () => {
116
      }, '!suggestWidgetVisible');
117
      // Handle typing the Tab key
118
      editor.addCommand(monaco.KeyCode.Tab, () => {
119
        focusNextElement();
120
      });
121
      editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Tab, () => {
122
        focusPrevElement();
123
      });
124
      // Handle Paste
125
      editor.onDidPaste(() => {
126
        // multiple rows will be merged to single row
127
        let newContent = '';
128
        const lineCount = textModel.getLineCount();
129
        // remove all line breaks
130
        for (let i = 0; i < lineCount; i += 1) {
131
          newContent += textModel.getLineContent(i + 1);
132
        }
133
        // Remove multiple spaces & tabs
134
        newContent = newContent.replace(/\s\s+/g, ' ');
135
        textModel.setValue(newContent);
136
        editor.setPosition({column: newContent.length + 1, lineNumber: 1});
137
      })
138
    }
139
  }
140
  // Get the autocompletion items if the language is Twig
141
  if (options.language === 'twig') {
142
    getCompletionItemsFromEndpoint(fieldType, codeEditorOptions, endpointUrl);
143
  }
144
  // Custom resizer to always keep the editor full-height, without needing to scroll
145
  let ignoreEvent = false;
146
  const updateHeight = () => {
147
    const width = editor.getLayoutInfo().width;
148
    const contentHeight = Math.min(1000, editor.getContentHeight());
149
    //container.style.width = `${width}px`;
150
    container.style.height = `${contentHeight}px`;
151
    try {
152
      ignoreEvent = true;
153
      editor.layout({width, height: contentHeight});
154
    } finally {
155
      ignoreEvent = false;
156
    }
157
  };
158
  editor.onDidContentSizeChange(updateHeight);
159
  updateHeight();
160
  // Handle the placeholder
161
  if (placeholderText !== '') {
162
    showPlaceholder('#' + placeholderId, editor.getValue());
163
    editor.onDidBlurEditorWidget(() => {
164
      showPlaceholder('#' + placeholderId, editor.getValue());
165
    });
166
    editor.onDidFocusEditorWidget(() => {
167
      hidePlaceholder('#' + placeholderId);
168
    });
169
  }
170
171
  /**
172
   * Move the focus to the next element
173
   */
174
  function focusNextElement(): void {
175
    const focusable = getFocusableElements();
176
    if (document.activeElement instanceof HTMLElement) {
177
      const index = focusable.indexOf(document.activeElement);
178
      if (index > -1) {
179
        const nextElement = focusable[index + 1] || focusable[0];
180
        nextElement.focus();
181
      }
182
    }
183
  }
184
185
  /**
186
   * Move the focus to the previous element
187
   */
188
  function focusPrevElement(): void {
189
    const focusable = getFocusableElements();
190
    if (document.activeElement instanceof HTMLElement) {
191
      const index = focusable.indexOf(document.activeElement);
192
      if (index > -1) {
193
        const prevElement = focusable[index - 1] || focusable[focusable.length];
194
        prevElement.focus();
195
      }
196
    }
197
  }
198
199
  /**
200
   * Get the focusable elements in the current form
201
   *
202
   * @returns {Array<HTMLElement>} - An array of HTMLElements that can be focusable
203
   */
204
  function getFocusableElements(): Array<HTMLElement> {
205
    let focusable: Array<HTMLElement> = [];
206
    // add all elements we want to include in our selection
207
    const focusableElements = 'a:not([disabled]), button:not([disabled]), select:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])';
208
    if (document.activeElement instanceof HTMLElement) {
209
      const activeElement: HTMLFormElement = <HTMLFormElement>document.activeElement;
210
      if (activeElement && activeElement.form) {
211
        focusable = Array.prototype.filter.call(activeElement.form.querySelectorAll(focusableElements),
212
          function (element) {
213
            if (element instanceof HTMLElement) {
214
              //check for visibility while always include the current activeElement
215
              return element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement
216
            }
217
            return false;
218
          });
219
      }
220
    }
221
222
    return focusable;
223
  }
224
225
  /**
226
   * Show the placeholder text
227
   *
228
   * @param {string} selector - The selector for the placeholder element
229
   * @param {string} value - The editor field's value (the text)
230
   */
231
  function showPlaceholder(selector: string, value: string): void {
232
    if (value === "") {
233
      const elem = <HTMLElement>document.querySelector(selector);
234
      if (elem !== null) {
235
        elem.style.display = "initial";
236
      }
237
    }
238
  }
239
240
  /**
241
   * Hide the placeholder text
242
   *
243
   * @param {string} selector - The selector for the placeholder element
244
   */
245
  function hidePlaceholder(selector: string): void {
246
    const elem = <HTMLElement>document.querySelector(selector);
247
    if (elem !== null) {
248
      elem.style.display = "none";
249
    }
250
  }
251
252
  return editor;
253
}
254
255
/**
256
 * Set the language for the Monaco editor instance
257
 *
258
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
259
 * @param {string | undefined} language - the editor language
260
 * @param {string} elementId - the element id used to create the monaco editor from
261
 */
262
function setMonacoEditorLanguage(editor: monaco.editor.IStandaloneCodeEditor, language: string | undefined, elementId: string): void {
263
  const containerId = elementId + '-monaco-editor';
264
  const iconId = elementId + '-monaco-language-icon';
265
  const container = document.querySelector('#' + containerId);
266
  if (container !== null) {
267
    if (typeof language !== "undefined") {
268
      const languageIcon = languageIcons[language] ?? languageIcons['default'] ?? null;
269
      const icon = document.createElement('div');
270
      monaco.editor.setModelLanguage(editor.getModel()!, language);
271
      icon.id = iconId;
272
      // Only add in the icon if one is available
273
      if (languageIcon !== null) {
274
        const languageTitle = language.charAt(0).toUpperCase() + language.slice(1) + ' ' + Craft.t('codeeditor', 'code is supported.');
275
        icon.classList.add('monaco-editor-codefield--icon');
276
        icon.setAttribute('title', languageTitle);
277
        icon.setAttribute('aria-hidden', 'true');
278
        icon.innerHTML = languageIcon;
279
      }
280
      // Replace the icon div
281
      const currentIcon = container.querySelector('#' + iconId);
282
      if (currentIcon) {
283
        container.replaceChild(icon, currentIcon);
284
      }
285
    }
286
  }
287
}
288
289
/**
290
 * Set the theme for the Monaco editor instance
291
 *
292
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
293
 * @param {string | undefined} theme - the editor theme
294
 */
295
function setMonacoEditorTheme(editor: monaco.editor.IStandaloneCodeEditor, theme: string | undefined): void {
296
  const editorTheme = theme ?? 'vs';
297
  editor.updateOptions({theme: editorTheme});
298
}
299
300
// Make the functions globally available
301
window.makeMonacoEditor = makeMonacoEditor;
302
window.setMonacoEditorLanguage = setMonacoEditorLanguage;
303
window.setMonacoEditorTheme = setMonacoEditorTheme;
304
305
export {makeMonacoEditor, setMonacoEditorLanguage, setMonacoEditorTheme};
306