Passed
Push — develop ( 91d1b6...2517e4 )
by Andrew
16:30
created

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

Complexity

Total Complexity 38
Complexity/F 7.6

Size

Lines of Code 254
Function Count 5

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 38
eloc 168
mnd 33
bc 33
fnc 5
dl 0
loc 254
rs 9.36
bpm 6.6
cpm 7.6
noi 0
c 0
b 0
f 0

3 Functions

Rating   Name   Duplication   Size   Complexity  
C code-editor.ts ➔ setMonacoEditorLanguage 0 30 9
A code-editor.ts ➔ setMonacoEditorTheme 0 14 5
F code-editor.ts ➔ makeMonacoEditor 0 157 24
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 {TabFocus} from 'monaco-editor/esm/vs/editor/browser/config/tabFocus.js';
37
import {getCompletionItemsFromEndpoint} from './twig-autocomplete';
38
import {languageIcons} from './language-icons'
39
import {defaultMonacoOptions} from "./default-monaco-options";
40
41
/**
42
 * Create a Monaco Editor instance
43
 *
44
 * @param {string} elementId - The id of the TextArea or Input element to replace with a Monaco editor
45
 * @param {string} fieldType - The field's passed in type, used for autocomplete caching
46
 * @param {monaco.editor.IStandaloneEditorConstructionOptions} monacoOptions - Monaco editor options
47
 * @param {string} codeEditorOptions - JSON encoded string of arbitrary CodeEditorOptions for the field
48
 * @param {string} endpointUrl - The controller action endpoint for generating autocomplete items
49
 */
50
function makeMonacoEditor(elementId: string, fieldType: string, monacoOptions: string, codeEditorOptions: string, endpointUrl: string): monaco.editor.IStandaloneCodeEditor | undefined {
51
  const textArea = <HTMLInputElement>document.getElementById(elementId);
52
  const container = document.createElement('div');
53
  const fieldOptions: CodeEditorOptions = JSON.parse(codeEditorOptions);
54
  const placeholderId = elementId + '-monaco-editor-placeholder';
55
  // If we can't find the passed in text area or if there is no parent node, return
56
  if (textArea === null || textArea.parentNode === null) {
57
    return;
58
  }
59
  // Monaco editor defaults, coalesced together
60
  const monacoEditorOptions: monaco.editor.IStandaloneEditorConstructionOptions = JSON.parse(monacoOptions);
61
  const options: monaco.editor.IStandaloneEditorConstructionOptions = {...defaultMonacoOptions, ...monacoEditorOptions, ...{value: textArea.value}}
62
  // Make a sibling div for the Monaco editor to live in
63
  container.id = elementId + '-monaco-editor';
64
  container.classList.add('monaco-editor', 'relative', 'box-content', 'monaco-editor-codefield', 'h-full');
65
  // Apply any passed in classes to the wrapper div
66
  const wrapperClass = fieldOptions.wrapperClass ?? '';
67
  if (wrapperClass !== '') {
68
    const cl = container.classList;
69
    const classArray = wrapperClass.trim().split(/\s+/);
70
    cl.add(...classArray);
71
  }
72
  // Create an empty div for the icon
73
  const displayLanguageIcon = fieldOptions.displayLanguageIcon ?? true;
74
  if (displayLanguageIcon) {
75
    const icon = document.createElement('div');
76
    icon.id = elementId + '-monaco-language-icon';
77
    container.appendChild(icon);
78
  }
79
  // Handle the placeholder text (if any)
80
  const placeholderText = fieldOptions.placeholderText ?? '';
81
  if (placeholderText !== '') {
82
    const placeholder = document.createElement('div');
83
    placeholder.id = elementId + '-monaco-editor-placeholder';
84
    placeholder.innerHTML = placeholderText;
85
    placeholder.classList.add('monaco-placeholder', 'p-2');
86
    container.appendChild(placeholder);
87
  }
88
  textArea.parentNode.insertBefore(container, textArea);
89
  textArea.style.display = 'none';
90
  // Create the Monaco editor
91
  const editor = monaco.editor.create(container, options);
92
  // Make the monaco editor instances available via the monacoEditorInstances global, since Twig macros can't return a value
93
  if (typeof window.monacoEditorInstances === 'undefined') {
94
    window.monacoEditorInstances = {};
95
  }
96
  window.monacoEditorInstances[elementId] = editor;
97
  // When the text is changed in the editor, sync it to the underlying TextArea input
98
  editor.onDidChangeModelContent(() => {
99
    textArea.value = editor.getValue();
100
  });
101
  // Add the language icon (if any)
102
  setMonacoEditorLanguage(editor, options.language, elementId);
103
  // Set the editor theme
104
  setMonacoEditorTheme(editor, options.theme);
105
  // ref: https://github.com/vikyd/vue-monaco-singleline/blob/master/src/monaco-singleline.vue#L150
106
  if ('singleLineEditor' in fieldOptions && fieldOptions.singleLineEditor) {
107
    const textModel: monaco.editor.ITextModel | null = editor.getModel();
108
    if (textModel !== null) {
109
      // Remove multiple spaces & tabs
110
      const text = textModel.getValue();
111
      textModel.setValue(text.replace(/\s\s+/g, ' '));
112
      // Handle the Find command
113
      editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
114
      });
115
      // Handle typing the Enter key
116
      editor.addCommand(monaco.KeyCode.Enter, () => {
117
      }, '!suggestWidgetVisible');
118
      // Enable TabeFocusMode ref: https://stackoverflow.com/questions/74202202/how-to-programatically-set-tabfocusmode-in-monaco-editor/74598917
119
      editor.onDidFocusEditorWidget(() => {
120
        TabFocus.setTabFocusMode(true);
121
      });
122
      // Handle Paste
123
      editor.onDidPaste(() => {
124
        // multiple rows will be merged to single row
125
        let newContent = '';
126
        const lineCount = textModel.getLineCount();
127
        // remove all line breaks
128
        for (let i = 0; i < lineCount; i += 1) {
129
          newContent += textModel.getLineContent(i + 1);
130
        }
131
        // Remove multiple spaces & tabs
132
        newContent = newContent.replace(/\s\s+/g, ' ');
133
        textModel.setValue(newContent);
134
        editor.setPosition({column: newContent.length + 1, lineNumber: 1});
135
      })
136
    }
137
  }
138
  // Get the autocompletion items if the language is Twig
139
  if (options.language === 'twig') {
140
    getCompletionItemsFromEndpoint(fieldType, codeEditorOptions, endpointUrl);
141
  }
142
  // Custom resizer to always keep the editor full-height, without needing to scroll
143
  let ignoreEvent = false;
144
  const updateHeight = () => {
145
    const width = editor.getLayoutInfo().width;
146
    const contentHeight = Math.min(1000, editor.getContentHeight());
147
    //container.style.width = `${width}px`;
148
    container.style.height = `${contentHeight}px`;
149
    try {
150
      ignoreEvent = true;
151
      editor.layout({width, height: contentHeight});
152
    } finally {
153
      ignoreEvent = false;
154
    }
155
  };
156
  editor.onDidContentSizeChange(updateHeight);
157
  updateHeight();
158
  // Handle the placeholder
159
  if (placeholderText !== '') {
160
    showPlaceholder('#' + placeholderId, editor.getValue());
161
    editor.onDidBlurEditorWidget(() => {
162
      showPlaceholder('#' + placeholderId, editor.getValue());
163
    });
164
    editor.onDidFocusEditorWidget(() => {
165
      hidePlaceholder('#' + placeholderId);
166
    });
167
  }
168
169
  /**
170
   * Show the placeholder text
171
   *
172
   * @param {string} selector - The selector for the placeholder element
173
   * @param {string} value - The editor field's value (the text)
174
   */
175
  function showPlaceholder(selector: string, value: string): void {
176
    if (value === "") {
177
      const elem = <HTMLElement>document.querySelector(selector);
178
      if (elem !== null) {
179
        elem.style.display = "initial";
180
      }
181
    }
182
  }
183
184
  /**
185
   * Hide the placeholder text
186
   *
187
   * @param {string} selector - The selector for the placeholder element
188
   */
189
  function hidePlaceholder(selector: string): void {
190
    const elem = <HTMLElement>document.querySelector(selector);
191
    if (elem !== null) {
192
      elem.style.display = "none";
193
    }
194
  }
195
196
  return editor;
197
}
198
199
/**
200
 * Set the language for the Monaco editor instance
201
 *
202
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
203
 * @param {string | undefined} language - the editor language
204
 * @param {string} elementId - the element id used to create the monaco editor from
205
 */
206
function setMonacoEditorLanguage(editor: monaco.editor.IStandaloneCodeEditor, language: string | undefined, elementId: string): void {
207
  const containerId = elementId + '-monaco-editor';
208
  const iconId = elementId + '-monaco-language-icon';
209
  const container = document.querySelector('#' + containerId);
210
  if (container !== null) {
211
    if (typeof language !== "undefined") {
212
      const languageIcon = languageIcons[language] ?? languageIcons['default'] ?? null;
213
      const icon = document.createElement('div');
214
      monaco.editor.setModelLanguage(editor.getModel()!, language);
215
      icon.id = iconId;
216
      // Only add in the icon if one is available
217
      if (languageIcon !== null) {
218
        const languageTitle = language.charAt(0).toUpperCase() + language.slice(1) + ' ' + Craft.t('codeeditor', 'code is supported.');
219
        icon.classList.add('monaco-editor-codefield--icon');
220
        icon.setAttribute('title', languageTitle);
221
        icon.setAttribute('aria-hidden', 'true');
222
        icon.innerHTML = languageIcon;
223
      }
224
      // Replace the icon div
225
      const currentIcon = container.querySelector('#' + iconId);
226
      if (currentIcon) {
227
        container.replaceChild(icon, currentIcon);
228
      }
229
    }
230
  }
231
}
232
233
/**
234
 * Set the theme for the Monaco editor instance
235
 *
236
 * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance
237
 * @param {string | undefined} theme - the editor theme
238
 */
239
function setMonacoEditorTheme(editor: monaco.editor.IStandaloneCodeEditor, theme: string | undefined): void {
240
  let editorTheme = theme ?? 'vs';
241
  if (editorTheme === 'auto') {
242
    const mediaQueryObj = window.matchMedia('(prefers-color-scheme: dark)');
243
    editorTheme = mediaQueryObj.matches ? 'vs-dark' : 'vs';
244
  }
245
  editor.updateOptions({theme: editorTheme});
246
}
247
248
// Make the functions globally available
249
window.makeMonacoEditor = makeMonacoEditor;
250
window.setMonacoEditorLanguage = setMonacoEditorLanguage;
251
window.setMonacoEditorTheme = setMonacoEditorTheme;
252
253
export {makeMonacoEditor, setMonacoEditorLanguage, setMonacoEditorTheme};
254