mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-13 06:20:24 +00:00
- Replace the [Monaco Editor](https://microsoft.github.io/monaco-editor/)
with [CodeMirror 6](https://codemirror.net/). This editor is used to
facilitate the 'Add file' and 'Edit file' functionality.
- Rationale:
- Monaco editor is a great and powerful editor, however for Forgejo's
purpose it acts more like a small IDE than a code editor and is doing
too much. In my limited user research the usage of editing files via
the web UI is largely for small changes that does not need the
features that Monaco editor provides.
- Monaco editor has no mobile support, Codemirror is very usable on mobile.
- Monaco editor pulls in large dependencies (for language support) and
by replacing it with Codemirror the amount of time that webpack needs
to build the frontend is reduced by 50% (~30s -> ~15s).
- The binary of Forgejo (build with `bindata` tag) is reduced by 2MiB.
- Codemirror is much more lightweight and should be more usable on
less powerful hardware, most notably the lazy loading is much faster
as codemirror uses less javascript.
- Because Codemirror is modular it is much easier to change the
behavior of the code editor if we wish to.
- Drawbacks:
- Codemirror is quite modular and as seen in `package.json` and in
`codeeditor.ts` we have to supply a lot more of its features to have
feature parity with Monaco editor.
- Monaco editor has great integrated language support (features that
an lsp would provide), Codemirror only has such language support to an
extend.
- Monaco editor has its famous command palette (known by many as its
also available in VSCode), this is not available in code mirror.
- Good to note:
- All features that was added on top of the monaco editor (such as
dynamically changing language support depending on the filename)
still works and the theme is based on the VSCode colors which largely
resembles the monaco editor.
- The code editor is still lazy-loaded (this is painfully clear by
reading how imports are passed around in `codeeditor.ts`).
- This change was privately tested by a few people, a few bugs were
found (and fixed) but no major drawbacks were noted for their usage of
the web editor.
- There's a "search" button in the top bar, so that search can be used
on mobile. It is otherwise only accessible via
<kbd>Ctrl</kbd>+<kbd>f</kbd>.
Co-authored-by: Beowulf <beowulf@beocode.eu>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Beowulf <beowulf@beocode.eu>
(cherry picked from commit 28e0af23fa)
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10697
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
200 lines
6.3 KiB
JavaScript
200 lines
6.3 KiB
JavaScript
import $ from 'jquery';
|
|
import {htmlEscape} from 'escape-goat';
|
|
import {createCodeEditor} from './codeeditor.ts';
|
|
import {hideElem, showElem, createElementFromHTML} from '../utils/dom.js';
|
|
import {initMarkupContent} from '../markup/content.js';
|
|
import {attachRefIssueContextPopup} from './contextpopup.js';
|
|
import {POST} from '../modules/fetch.js';
|
|
import {initTab} from '../modules/tab.ts';
|
|
import {showModal} from '../modules/modal.ts';
|
|
|
|
function initEditPreviewTab($form) {
|
|
const $tabMenu = $form.find('.switch');
|
|
initTab($tabMenu[0]);
|
|
const $previewTab = $tabMenu.find(
|
|
`.item[data-tab="${$tabMenu.data('preview')}"]`,
|
|
);
|
|
if ($previewTab.length) {
|
|
$previewTab.on('click', async function () {
|
|
const $this = $(this);
|
|
let context = `${$this.data('context')}/`;
|
|
const mode = $this.data('markup-mode') || 'comment';
|
|
const $treePathEl = $form.find('input#tree_path');
|
|
if ($treePathEl.length > 0) {
|
|
context += $treePathEl.val();
|
|
}
|
|
context = context.substring(0, context.lastIndexOf('/'));
|
|
|
|
const formData = new FormData();
|
|
formData.append('mode', mode);
|
|
formData.append('context', context);
|
|
formData.append('branch_path', $this.data('branch-path'));
|
|
formData.append(
|
|
'text',
|
|
$form.find(`.tab[data-tab="${$tabMenu.data('write')}"] textarea`).val(),
|
|
);
|
|
formData.append('file_path', $treePathEl.val());
|
|
try {
|
|
const response = await POST($this.data('url'), {data: formData});
|
|
const data = await response.text();
|
|
const $previewPanel = $form.find(
|
|
`.tab[data-tab="${$tabMenu.data('preview')}"]`,
|
|
);
|
|
renderPreviewPanelContent($previewPanel, data);
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function initEditorForm() {
|
|
const $form = $('.repository .edit.form');
|
|
if (!$form) return;
|
|
initEditPreviewTab($form);
|
|
}
|
|
|
|
function getCursorPosition($e) {
|
|
const el = $e.get(0);
|
|
let pos = 0;
|
|
if ('selectionStart' in el) {
|
|
pos = el.selectionStart;
|
|
} else if ('selection' in document) {
|
|
el.focus();
|
|
const Sel = document.selection.createRange();
|
|
const SelLength = document.selection.createRange().text.length;
|
|
Sel.moveStart('character', -el.value.length);
|
|
pos = Sel.text.length - SelLength;
|
|
}
|
|
return pos;
|
|
}
|
|
|
|
export function initRepoEditor() {
|
|
initEditorForm();
|
|
|
|
$('.js-quick-pull-choice-option').on('change', function () {
|
|
if ($(this).val() === 'commit-to-new-branch') {
|
|
showElem('.quick-pull-branch-name');
|
|
document.querySelector('.quick-pull-branch-name input').required = true;
|
|
} else {
|
|
hideElem('.quick-pull-branch-name');
|
|
document.querySelector('.quick-pull-branch-name input').required = false;
|
|
}
|
|
$('#commit-button').text(this.getAttribute('button_text'));
|
|
});
|
|
|
|
const joinTreePath = ($fileNameEl) => {
|
|
const parts = [];
|
|
$('.breadcrumb span.section').each(function () {
|
|
const $element = $(this);
|
|
if ($element.find('a').length) {
|
|
parts.push($element.find('a').text());
|
|
} else {
|
|
parts.push($element.text());
|
|
}
|
|
});
|
|
if ($fileNameEl.val()) parts.push($fileNameEl.val());
|
|
$('#tree_path').val(parts.join('/'));
|
|
};
|
|
|
|
const $editFilename = $('#file-name');
|
|
$editFilename.on('input', function () {
|
|
const parts = $(this).val().split('/');
|
|
|
|
if (parts.length > 1) {
|
|
for (let i = 0; i < parts.length; ++i) {
|
|
const value = parts[i];
|
|
if (i < parts.length - 1) {
|
|
if (value.length) {
|
|
$editFilename[0].before(
|
|
createElementFromHTML(
|
|
`<span class="section"><a href="#">${htmlEscape(value)}</a></span>`,
|
|
),
|
|
);
|
|
$editFilename[0].before(
|
|
createElementFromHTML(`<div class="breadcrumb-divider">/</div>`),
|
|
);
|
|
}
|
|
} else {
|
|
$(this).val(value);
|
|
}
|
|
this.setSelectionRange(0, 0);
|
|
}
|
|
}
|
|
|
|
joinTreePath($(this));
|
|
});
|
|
|
|
$editFilename.on('keydown', function (e) {
|
|
const $section = $('.breadcrumb span.section');
|
|
|
|
// Jump back to last directory once the filename is empty
|
|
if (
|
|
e.code === 'Backspace' &&
|
|
getCursorPosition($(this)) === 0 &&
|
|
$section.length > 0
|
|
) {
|
|
e.preventDefault();
|
|
const $divider = $('.breadcrumb .breadcrumb-divider');
|
|
const value = $section.last().find('a').text();
|
|
$(this).val(value + $(this).val());
|
|
this.setSelectionRange(value.length, value.length);
|
|
$section.last().remove();
|
|
$divider.last().remove();
|
|
joinTreePath($(this));
|
|
}
|
|
});
|
|
|
|
const $editArea = $('.repository.editor textarea#edit_area');
|
|
if (!$editArea.length) return;
|
|
|
|
(async () => {
|
|
const editor = await createCodeEditor($editArea[0], $editFilename[0]);
|
|
|
|
// Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
|
|
// to enable or disable the commit button
|
|
const commitButton = document.getElementById('commit-button');
|
|
const $editForm = $('.ui.edit.form');
|
|
const dirtyFileClass = 'dirty-file';
|
|
|
|
// Disabling the button at the start
|
|
if ($('input[name="page_has_posted"]').val() !== 'true') {
|
|
commitButton.disabled = true;
|
|
}
|
|
|
|
// Registering a custom listener for the file path and the file content
|
|
$editForm.areYouSure({
|
|
silent: true,
|
|
dirtyClass: dirtyFileClass,
|
|
fieldSelector: ':input:not(.commit-form-wrapper :input)',
|
|
change($form) {
|
|
const dirty = $form[0]?.classList.contains(dirtyFileClass);
|
|
commitButton.disabled = !dirty;
|
|
},
|
|
});
|
|
|
|
// Update the editor from query params, if available,
|
|
// only after the dirtyFileClass initialization
|
|
const params = new URLSearchParams(window.location.search);
|
|
const value = params.get('value');
|
|
if (value) {
|
|
editor.setValue(value);
|
|
}
|
|
|
|
commitButton?.addEventListener('click', (e) => {
|
|
// A modal which asks if an empty file should be committed
|
|
if (!$editArea.val()) {
|
|
e.preventDefault();
|
|
showModal('edit-empty-content-modal', () => { document.querySelector('.edit.form').requestSubmit()});
|
|
}
|
|
});
|
|
})();
|
|
}
|
|
|
|
export function renderPreviewPanelContent($panelPreviewer, data) {
|
|
$panelPreviewer.html(data);
|
|
initMarkupContent();
|
|
|
|
const $refIssues = $panelPreviewer.find('p .ref-issue');
|
|
attachRefIssueContextPopup($refIssues);
|
|
}
|