Passed
Push — develop ( 96aa2b...608a2f )
by Andrew
06:15
created

src/web/assets/src/js/codefield.ts   A

Complexity

Total Complexity 27
Complexity/F 4.5

Size

Lines of Code 275
Function Count 6

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 27
eloc 181
mnd 21
bc 21
fnc 6
dl 0
loc 275
bpm 3.5
cpm 4.5
noi 0
c 0
b 0
f 0
rs 10

1 Function

Rating   Name   Duplication   Size   Complexity  
F codefield.ts ➔ makeMonacoEditor 0 202 27
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
  let Craft: any;
19
20
  interface Window {
21
    twigfieldBaseAssetsUrl: 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.twigfieldBaseAssetsUrl;
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
36
// The default EditorOptions for the Monaco editor instance
37
// ref: https://microsoft.github.io/monaco-editor/api/enums/monaco.editor.EditorOption.html
38
const defaultOptions: monaco.editor.IStandaloneEditorConstructionOptions = {
39
  language: 'twig',
40
  theme: 'vs',
41
  automaticLayout: true,
42
  tabIndex: 0,
43
  // Disable sidebar line numbers
44
  lineNumbers: 'off',
45
  glyphMargin: false,
46
  folding: false,
47
  // Undocumented see https://github.com/Microsoft/vscode/issues/30795#issuecomment-410998882
48
  lineDecorationsWidth: 0,
49
  lineNumbersMinChars: 0,
50
  // Disable the current line highlight
51
  renderLineHighlight: 'none',
52
  wordWrap: 'on',
53
  scrollBeyondLastLine: false,
54
  scrollbar: {
55
    vertical: 'hidden',
56
    horizontal: 'auto',
57
    alwaysConsumeMouseWheel: false,
58
    handleMouseWheel: false,
59
  },
60
  fontSize: 14,
61
  fontFamily: 'SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace',
62
  minimap: {
63
    enabled: false
64
  },
65
};
66
67
/**
68
 * Create a Monaco Editor instance
69
 *
70
 * @param {string} elementId - The id of the TextArea or Input element to replace with a Monaco editor
71
 * @param {string} fieldType - The field's passed in type, used for autocomplete caching
72
 * @param {string} wrapperClass - Classes that should be added to the field's wrapper <div>
73
 * @param {IStandaloneEditorConstructionOptions} editorOptions - Monaco editor options ref: https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneEditorConstructionOptions.html
74
 * @param {string} codefieldOptions - JSON encoded string of arbitrary CodefieldOptions for the field
75
 * @param {string} endpointUrl - The controller action endpoint for generating autocomplete items
76
 * @param {string} placeholderText - Placeholder text to use for the field
77
 */
78
function makeMonacoEditor(elementId: string, fieldType: string, wrapperClass: string, editorOptions: string, codefieldOptions: string, endpointUrl: string, placeholderText: string = '') {
79
  const textArea = <HTMLInputElement>document.getElementById(elementId);
80
  const container = document.createElement('div');
81
  const fieldOptions: CodefieldOptions = JSON.parse(codefieldOptions);
82
  const placeholderId = elementId + '-monaco-editor-placeholder';
83
  // If we can't find the passed in text area or if there is no parent node, return
84
  if (textArea === null || textArea.parentNode === null) {
85
    return;
86
  }
87
  // Monaco editor defaults, coalesced together
88
  const monacoEditorOptions: monaco.editor.IStandaloneEditorConstructionOptions = JSON.parse(editorOptions);
89
  let options: monaco.editor.IStandaloneEditorConstructionOptions = {...defaultOptions, ...monacoEditorOptions, ...{value: textArea.value}}
90
  // Make a sibling div for the Monaco editor to live in
91
  container.id = elementId + '-monaco-editor';
92
  container.classList.add('relative', 'box-content', 'monaco-editor-twigfield', 'h-full');
93
  // Add the icon in, if there is one
94
  const iconHtml = typeof options.language === "undefined" ? null : languageIcons[options.language];
95
  if (iconHtml) {
96
    const icon = document.createElement('div');
97
    icon.classList.add('monaco-editor-twigfield--icon');
98
    icon.setAttribute('title', Craft.t('twigfield', 'Twig code is supported.'));
99
    icon.setAttribute('aria-hidden', 'true');
100
    icon.innerHTML = iconHtml;
101
    container.appendChild(icon);
102
  }
103
  // Apply any passed in classes to the wrapper div
104
  if (wrapperClass !== '') {
105
    const cl = container.classList;
106
    const classArray = wrapperClass.trim().split(/\s+/);
107
    cl.add(...classArray);
108
  }
109
  // Handle the placeholder text (if any)
110
  if (placeholderText !== '') {
111
    let placeholder = document.createElement('div');
112
    placeholder.id = elementId + '-monaco-editor-placeholder';
113
    placeholder.innerHTML = placeholderText;
114
    placeholder.classList.add('monaco-placeholder', 'p-2');
115
    container.appendChild(placeholder);
116
  }
117
  textArea.parentNode.insertBefore(container, textArea);
118
  textArea.style.display = 'none';
119
  // Create the Monaco editor
120
  let editor = monaco.editor.create(container, options);
121
  // When the text is changed in the editor, sync it to the underlying TextArea input
122
  editor.onDidChangeModelContent(() => {
123
    textArea.value = editor.getValue();
124
  });
125
  // ref: https://github.com/vikyd/vue-monaco-singleline/blob/master/src/monaco-singleline.vue#L150
126
  if ('singleLineEditor' in fieldOptions && fieldOptions.singleLineEditor) {
127
    const textModel: monaco.editor.ITextModel | null = editor.getModel();
128
    if (textModel !== null) {
129
      // Remove multiple spaces & tabs
130
      const text = textModel.getValue();
131
      textModel.setValue(text.replace(/\s\s+/g, ' '));
132
      // Handle the Find command
133
      editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
134
      });
135
      // Handle typing the Enter key
136
      editor.addCommand(monaco.KeyCode.Enter, () => {
137
      }, '!suggestWidgetVisible');
138
      // Handle typing the Tab key
139
      editor.addCommand(monaco.KeyCode.Tab, () => {
140
        focusNextElement();
141
      });
142
      editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Tab, () => {
143
        focusPrevElement();
144
      });
145
      // Handle Paste
146
      editor.onDidPaste(() => {
147
        // multiple rows will be merged to single row
148
        let newContent = '';
149
        const lineCount = textModel.getLineCount();
150
        // remove all line breaks
151
        for (let i = 0; i < lineCount; i += 1) {
152
          newContent += textModel.getLineContent(i + 1);
153
        }
154
        // Remove multiple spaces & tabs
155
        newContent = newContent.replace(/\s\s+/g, ' ');
156
        textModel.setValue(newContent);
157
        editor.setPosition({column: newContent.length + 1, lineNumber: 1});
158
      })
159
    }
160
  }
161
  // Get the autocompletion items
162
  getCompletionItemsFromEndpoint(fieldType, codefieldOptions, endpointUrl);
163
  // Custom resizer to always keep the editor full-height, without needing to scroll
164
  let ignoreEvent = false;
165
  const updateHeight = () => {
166
    const width = editor.getLayoutInfo().width;
167
    const contentHeight = Math.min(1000, editor.getContentHeight());
168
    //container.style.width = `${width}px`;
169
    container.style.height = `${contentHeight}px`;
170
    try {
171
      ignoreEvent = true;
172
      editor.layout({width, height: contentHeight});
173
    } finally {
174
      ignoreEvent = false;
175
    }
176
  };
177
  editor.onDidContentSizeChange(updateHeight);
178
  updateHeight();
179
  // Handle the placeholder
180
  if (placeholderText !== '') {
181
    showPlaceholder('#' + placeholderId, editor.getValue());
182
    editor.onDidBlurEditorWidget(() => {
183
      showPlaceholder('#' + placeholderId, editor.getValue());
184
    });
185
    editor.onDidFocusEditorWidget(() => {
186
      hidePlaceholder('#' + placeholderId);
187
    });
188
  }
189
190
  /**
191
   * Move the focus to the next element
192
   */
193
  function focusNextElement() {
194
    const focusable = getFocusableElements();
195
    if (document.activeElement instanceof HTMLFormElement) {
196
      const index = focusable.indexOf(document.activeElement);
197
      if (index > -1) {
198
        const nextElement = focusable[index + 1] || focusable[0];
199
        nextElement.focus();
200
      }
201
    }
202
  }
203
204
  /**
205
   * Move the focus to the previous element
206
   */
207
  function focusPrevElement() {
208
    const focusable = getFocusableElements();
209
    if (document.activeElement instanceof HTMLFormElement) {
210
      const index = focusable.indexOf(document.activeElement);
211
      if (index > -1) {
212
        const prevElement = focusable[index - 1] || focusable[focusable.length];
213
        prevElement.focus();
214
      }
215
    }
216
  }
217
218
  /**
219
   * Get the focusable elements in the current form
220
   *
221
   * @returns {Array<HTMLElement>} - An array of HTMLElements that can be focusable
222
   */
223
  function getFocusableElements(): Array<HTMLElement> {
224
    let focusable: Array<HTMLElement> = [];
225
    // add all elements we want to include in our selection
226
    const focusableElements = 'a:not([disabled]), button:not([disabled]), select:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])';
227
    if (document.activeElement instanceof HTMLFormElement) {
228
      const activeElement: HTMLFormElement = document.activeElement;
229
      if (activeElement && activeElement.form) {
230
        focusable = Array.prototype.filter.call(activeElement.form.querySelectorAll(focusableElements),
231
          function (element) {
232
            if (element instanceof HTMLElement) {
233
              //check for visibility while always include the current activeElement
234
              return element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement
235
            }
236
            return false;
237
          });
238
      }
239
    }
240
241
    return focusable;
242
  }
243
244
  /**
245
   * Show the placeholder text
246
   *
247
   * @param {string} selector - The selector for the placeholder element
248
   * @param {string} value - The editor field's value (the text)
249
   */
250
  function showPlaceholder(selector: string, value: string) {
251
    if (value === "") {
252
      const elem = <HTMLElement>document.querySelector(selector);
253
      if (elem !== null) {
254
        elem.style.display = "initial";
255
      }
256
    }
257
  }
258
259
  /**
260
   * Hide the placeholder text
261
   *
262
   * @param {string} selector - The selector for the placeholder element
263
   */
264
  function hidePlaceholder(selector: string) {
265
    const elem = <HTMLElement>document.querySelector(selector);
266
    if (elem !== null) {
267
      elem.style.display = "none";
268
    }
269
  }
270
}
271
272
window.makeMonacoEditor = makeMonacoEditor;
273
274
export default makeMonacoEditor;
275
276