1import tinycolor from 'tinycolor2';
2import {basename, extname, isObject, isDarkTheme} from '../utils.js';
3import {onInputDebounce} from '../utils/dom.js';
4
5const languagesByFilename = {};
6const languagesByExt = {};
7
8const baseOptions = {
9 fontFamily: 'var(--fonts-monospace)',
10 fontSize: 14, // https://github.com/microsoft/monaco-editor/issues/2242
11 guides: {bracketPairs: false, indentation: false},
12 links: false,
13 minimap: {enabled: false},
14 occurrencesHighlight: 'off',
15 overviewRulerLanes: 0,
16 renderLineHighlight: 'all',
17 renderLineHighlightOnlyWhenFocus: true,
18 rulers: false,
19 scrollbar: {horizontalScrollbarSize: 6, verticalScrollbarSize: 6},
20 scrollBeyondLastLine: false,
21 automaticLayout: true,
22};
23
24function getEditorconfig(input) {
25 try {
26 return JSON.parse(input.getAttribute('data-editorconfig'));
27 } catch {
28 return null;
29 }
30}
31
32function initLanguages(monaco) {
33 for (const {filenames, extensions, id} of monaco.languages.getLanguages()) {
34 for (const filename of filenames || []) {
35 languagesByFilename[filename] = id;
36 }
37 for (const extension of extensions || []) {
38 languagesByExt[extension] = id;
39 }
40 }
41}
42
43function getLanguage(filename) {
44 return languagesByFilename[filename] || languagesByExt[extname(filename)] || 'plaintext';
45}
46
47function updateEditor(monaco, editor, filename, lineWrapExts) {
48 editor.updateOptions(getFileBasedOptions(filename, lineWrapExts));
49 const model = editor.getModel();
50 const language = model.getLanguageId();
51 const newLanguage = getLanguage(filename);
52 if (language !== newLanguage) monaco.editor.setModelLanguage(model, newLanguage);
53}
54
55// export editor for customization - https://github.com/go-gitea/gitea/issues/10409
56function exportEditor(editor) {
57 if (!window.codeEditors) window.codeEditors = [];
58 if (!window.codeEditors.includes(editor)) window.codeEditors.push(editor);
59}
60
61export async function createMonaco(textarea, filename, editorOpts) {
62 const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor');
63
64 initLanguages(monaco);
65 let {language, ...other} = editorOpts;
66 if (!language) language = getLanguage(filename);
67
68 const container = document.createElement('div');
69 container.className = 'monaco-editor-container';
70 textarea.parentNode.append(container);
71
72 // https://github.com/microsoft/monaco-editor/issues/2427
73 // also, monaco can only parse 6-digit hex colors, so we convert the colors to that format
74 const styles = window.getComputedStyle(document.documentElement);
75 const getColor = (name) => tinycolor(styles.getPropertyValue(name).trim()).toString('hex6');
76
77 monaco.editor.defineTheme('gitea', {
78 base: isDarkTheme() ? 'vs-dark' : 'vs',
79 inherit: true,
80 rules: [
81 {
82 background: getColor('--color-code-bg'),
83 },
84 ],
85 colors: {
86 'editor.background': getColor('--color-code-bg'),
87 'editor.foreground': getColor('--color-text'),
88 'editor.inactiveSelectionBackground': getColor('--color-primary-light-4'),
89 'editor.lineHighlightBackground': getColor('--color-editor-line-highlight'),
90 'editor.selectionBackground': getColor('--color-primary-light-3'),
91 'editor.selectionForeground': getColor('--color-primary-light-3'),
92 'editorLineNumber.background': getColor('--color-code-bg'),
93 'editorLineNumber.foreground': getColor('--color-secondary-dark-6'),
94 'editorWidget.background': getColor('--color-body'),
95 'editorWidget.border': getColor('--color-secondary'),
96 'input.background': getColor('--color-input-background'),
97 'input.border': getColor('--color-input-border'),
98 'input.foreground': getColor('--color-input-text'),
99 'scrollbar.shadow': getColor('--color-shadow'),
100 'progressBar.background': getColor('--color-primary'),
101 },
102 });
103
104 const editor = monaco.editor.create(container, {
105 value: textarea.value,
106 theme: 'gitea',
107 language,
108 ...other,
109 });
110
111 monaco.editor.addKeybindingRules([
112 {keybinding: monaco.KeyCode.Enter, command: null}, // disable enter from accepting code completion
113 ]);
114
115 const model = editor.getModel();
116 model.onDidChangeContent(() => {
117 textarea.value = editor.getValue({preserveBOM: true});
118 textarea.dispatchEvent(new Event('change')); // seems to be needed for jquery-are-you-sure
119 });
120
121 exportEditor(editor);
122
123 const loading = document.querySelector('.editor-loading');
124 if (loading) loading.remove();
125
126 return {monaco, editor};
127}
128
129function getFileBasedOptions(filename, lineWrapExts) {
130 return {
131 wordWrap: (lineWrapExts || []).includes(extname(filename)) ? 'on' : 'off',
132 };
133}
134
135function togglePreviewDisplay(previewable) {
136 const previewTab = document.querySelector('a[data-tab="preview"]');
137 if (!previewTab) return;
138
139 if (previewable) {
140 const newUrl = (previewTab.getAttribute('data-url') || '').replace(/(.*)\/.*/, `$1/markup`);
141 previewTab.setAttribute('data-url', newUrl);
142 previewTab.style.display = '';
143 } else {
144 previewTab.style.display = 'none';
145 // If the "preview" tab was active, user changes the filename to a non-previewable one,
146 // then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
147 if (previewTab.classList.contains('active')) {
148 const writeTab = document.querySelector('a[data-tab="write"]');
149 writeTab.click();
150 }
151 }
152}
153
154export async function createCodeEditor(textarea, filenameInput) {
155 const filename = basename(filenameInput.value);
156 const previewableExts = new Set((textarea.getAttribute('data-previewable-extensions') || '').split(','));
157 const lineWrapExts = (textarea.getAttribute('data-line-wrap-extensions') || '').split(',');
158 const previewable = previewableExts.has(extname(filename));
159 const editorConfig = getEditorconfig(filenameInput);
160
161 togglePreviewDisplay(previewable);
162
163 const {monaco, editor} = await createMonaco(textarea, filename, {
164 ...baseOptions,
165 ...getFileBasedOptions(filenameInput.value, lineWrapExts),
166 ...getEditorConfigOptions(editorConfig),
167 });
168
169 filenameInput.addEventListener('input', onInputDebounce(() => {
170 const filename = filenameInput.value;
171 const previewable = previewableExts.has(extname(filename));
172 togglePreviewDisplay(previewable);
173 updateEditor(monaco, editor, filename, lineWrapExts);
174 }));
175
176 return editor;
177}
178
179function getEditorConfigOptions(ec) {
180 if (!isObject(ec)) return {};
181
182 const opts = {};
183 opts.detectIndentation = !('indent_style' in ec) || !('indent_size' in ec);
184 if ('indent_size' in ec) opts.indentSize = Number(ec.indent_size);
185 if ('tab_width' in ec) opts.tabSize = Number(ec.tab_width) || opts.indentSize;
186 if ('max_line_length' in ec) opts.rulers = [Number(ec.max_line_length)];
187 opts.trimAutoWhitespace = ec.trim_trailing_whitespace === true;
188 opts.insertSpaces = ec.indent_style === 'space';
189 opts.useTabStops = ec.indent_style === 'tab';
190 return opts;
191}