Passed
Push — develop ( 0c67b4...5c477f )
by Andrew
06:00
created

code-editor.ts ➔ setMonacoEditorLanguage   C

Complexity

Conditions 9

Size

Total Lines 32
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

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