1
0
Fork 0
forked from forgejo/forgejo

Introduce GitHub markdown editor, keep EasyMDE as fallback (#23876)

The first step of the plan

* #23290

Thanks to @silverwind for the first try in #15394 . Close #10729 and a
lot of related issues.

The EasyMDE is not removed, now it works as a fallback, users can switch
between these two editors.

Editor list:

* Issue / PR comment
* Issue / PR comment edit
* Issue / PR comment quote reply
* PR diff view, inline comment
* PR diff view, inline comment edit
* PR diff view, inline comment quote reply
* Release editor
* Wiki editor

Some editors have attached dropzone

Screenshots:

<details>


![image](https://user-images.githubusercontent.com/2114189/229363558-7e44dcd4-fb6d-48a0-92f8-bd12f57bb0a0.png)


![image](https://user-images.githubusercontent.com/2114189/229363566-781489c8-5306-4347-9714-d71af5d5b0b1.png)


![image](https://user-images.githubusercontent.com/2114189/229363771-1717bf5c-0f2a-4fc2-ba84-4f5b2a343a11.png)


![image](https://user-images.githubusercontent.com/2114189/229363793-ad362d0f-a045-47bd-8f9d-05a9a842bb39.png)

</details>

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
wxiaoguang 2023-04-03 18:06:57 +08:00 committed by GitHub
parent d67e40684f
commit 5cc0801de9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 763 additions and 833 deletions

View file

@ -1,11 +1,8 @@
import $ from 'jquery';
import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js';
import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
import {
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
initRepoIssueStatusButton, initRepoIssueTitleEdit, initRepoIssueWipToggle,
initRepoIssueTitleEdit, initRepoIssueWipToggle,
initRepoPullRequestUpdate, updateIssuesMeta, handleReply
} from './repo-issue.js';
import {initUnicodeEscapeButton} from './repo-unicode-escape.js';
@ -19,27 +16,27 @@ import {
import {initCitationFileCopyContent} from './citation.js';
import {initCompLabelEdit} from './comp/LabelEdit.js';
import {initRepoDiffConversationNav} from './repo-diff.js';
import {attachTribute} from './tribute.js';
import {createDropzone} from './dropzone.js';
import {initCommentContent, initMarkupContent} from '../markup/content.js';
import {initCompReactionSelector} from './comp/ReactionSelector.js';
import {initRepoSettingBranches} from './repo-settings.js';
import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js';
import {hideElem, showElem} from '../utils/dom.js';
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
import {attachRefIssueContextPopup} from './contextpopup.js';
const {csrfToken} = window.config;
// if there are draft comments (more than 20 chars), confirm before reloading, to avoid losing comments
// if there are draft comments, confirm before reloading, to avoid losing comments
function reloadConfirmDraftComment() {
const commentTextareas = [
document.querySelector('.edit-content-zone:not(.gt-hidden) textarea'),
document.querySelector('.edit_area'),
document.querySelector('#comment-form textarea'),
];
for (const textarea of commentTextareas) {
// Most users won't feel too sad if they lose a comment with 10 or 20 chars, they can re-type these in seconds.
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
if (textarea && textarea.value.trim().length > 20) {
if (textarea && textarea.value.trim().length > 10) {
textarea.parentElement.scrollIntoView();
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
return;
@ -85,25 +82,20 @@ export function initRepoCommentForm() {
});
}
(async () => {
const $statusButton = $('#status-button');
for (const textarea of $commentForm.find('textarea:not(.review-textarea, .no-easymde)')) {
// Don't initialize EasyMDE for the dormant #edit-content-form
if (textarea.closest('#edit-content-form')) {
continue;
}
const easyMDE = await createCommentEasyMDE(textarea, {
'onChange': () => {
const value = easyMDE?.value().trim();
$statusButton.text($statusButton.attr(value.length === 0 ? 'data-status' : 'data-status-and-comment'));
},
});
initEasyMDEImagePaste(easyMDE, $commentForm.find('.dropzone'));
}
})();
const $statusButton = $('#status-button');
$statusButton.on('click', (e) => {
e.preventDefault();
$('#status').val($statusButton.data('status-val'));
$('#comment-form').trigger('submit');
});
const _promise = initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), {
onContentChanged(editor) {
$statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status'));
},
});
initBranchSelector();
initCompMarkupContentPreviewTab($commentForm);
// List submits
function initListSubmits(selector, outerSelector) {
@ -275,7 +267,7 @@ export function initRepoCommentForm() {
} else if (input_id === '#project_id') {
icon = svg('octicon-project', 18, 'gt-mr-3');
} else if (input_id === '#assignee_id') {
icon = `<img class="ui avatar image gt-mr-3" src=${$(this).data('avatar')}>`;
icon = `<img class="ui avatar image gt-mr-3" alt="avatar" src=${$(this).data('avatar')}>`;
}
$list.find('.selected').html(`
@ -322,162 +314,148 @@ async function onEditContent(event) {
const $editContentZone = $segment.find('.edit-content-zone');
const $renderContent = $segment.find('.render-content');
const $rawContent = $segment.find('.raw-content');
let $textarea;
let easyMDE;
// Setup new form
if ($editContentZone.html().length === 0) {
$editContentZone.html($('#edit-content-form').html());
$textarea = $editContentZone.find('textarea');
await attachTribute($textarea.get(), {mentions: true, emoji: true});
let comboMarkdownEditor;
let dz;
const $dropzone = $editContentZone.find('.dropzone');
if ($dropzone.length === 1) {
$dropzone.data('saved', false);
const setupDropzone = async ($dropzone) => {
if ($dropzone.length === 0) return null;
$dropzone.data('saved', false);
const fileUuidDict = {};
dz = await createDropzone($dropzone[0], {
url: $dropzone.data('upload-url'),
headers: {'X-Csrf-Token': csrfToken},
maxFiles: $dropzone.data('max-file'),
maxFilesize: $dropzone.data('max-size'),
acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
addRemoveLinks: true,
dictDefaultMessage: $dropzone.data('default-message'),
dictInvalidFileType: $dropzone.data('invalid-input-type'),
dictFileTooBig: $dropzone.data('file-too-big'),
dictRemoveFile: $dropzone.data('remove-file'),
timeout: 0,
thumbnailMethod: 'contain',
thumbnailWidth: 480,
thumbnailHeight: 480,
init() {
this.on('success', (file, data) => {
file.uuid = data.uuid;
fileUuidDict[file.uuid] = {submitted: false};
const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
$dropzone.find('.files').append(input);
const fileUuidDict = {};
const dz = await createDropzone($dropzone[0], {
url: $dropzone.data('upload-url'),
headers: {'X-Csrf-Token': csrfToken},
maxFiles: $dropzone.data('max-file'),
maxFilesize: $dropzone.data('max-size'),
acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
addRemoveLinks: true,
dictDefaultMessage: $dropzone.data('default-message'),
dictInvalidFileType: $dropzone.data('invalid-input-type'),
dictFileTooBig: $dropzone.data('file-too-big'),
dictRemoveFile: $dropzone.data('remove-file'),
timeout: 0,
thumbnailMethod: 'contain',
thumbnailWidth: 480,
thumbnailHeight: 480,
init() {
this.on('success', (file, data) => {
file.uuid = data.uuid;
fileUuidDict[file.uuid] = {submitted: false};
const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
$dropzone.find('.files').append(input);
});
this.on('removedfile', (file) => {
$(`#${file.uuid}`).remove();
if ($dropzone.data('remove-url') && !fileUuidDict[file.uuid].submitted) {
$.post($dropzone.data('remove-url'), {
file: file.uuid,
_csrf: csrfToken,
});
}
});
this.on('submit', () => {
$.each(fileUuidDict, (fileUuid) => {
fileUuidDict[fileUuid].submitted = true;
});
this.on('removedfile', (file) => {
$(`#${file.uuid}`).remove();
if ($dropzone.data('remove-url') && !fileUuidDict[file.uuid].submitted) {
$.post($dropzone.data('remove-url'), {
file: file.uuid,
_csrf: csrfToken,
});
}
});
this.on('submit', () => {
$.each(fileUuidDict, (fileUuid) => {
fileUuidDict[fileUuid].submitted = true;
});
this.on('reload', () => {
$.getJSON($editContentZone.data('attachment-url'), (data) => {
dz.removeAllFiles(true);
$dropzone.find('.files').empty();
$.each(data, function () {
const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`;
dz.emit('addedfile', this);
dz.emit('thumbnail', this, imgSrc);
dz.emit('complete', this);
dz.files.push(this);
fileUuidDict[this.uuid] = {submitted: true};
$dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%');
const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid);
$dropzone.find('.files').append(input);
});
});
this.on('reload', () => {
$.getJSON($editContentZone.data('attachment-url'), (data) => {
dz.removeAllFiles(true);
$dropzone.find('.files').empty();
$.each(data, function () {
const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`;
dz.emit('addedfile', this);
dz.emit('thumbnail', this, imgSrc);
dz.emit('complete', this);
dz.files.push(this);
fileUuidDict[this.uuid] = {submitted: true};
$dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%');
const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid);
$dropzone.find('.files').append(input);
});
});
});
},
});
});
},
});
dz.emit('reload');
return dz;
};
const cancelAndReset = (dz) => {
showElem($renderContent);
hideElem($editContentZone);
if (dz) {
dz.emit('reload');
}
// Give new write/preview data-tab name to distinguish from others
const $editContentForm = $editContentZone.find('.ui.comment.form');
const $tabMenu = $editContentForm.find('.tabular.menu');
$tabMenu.attr('data-write', $editContentZone.data('write'));
$tabMenu.attr('data-preview', $editContentZone.data('preview'));
$tabMenu.find('.write.item').attr('data-tab', $editContentZone.data('write'));
$tabMenu.find('.preview.item').attr('data-tab', $editContentZone.data('preview'));
$editContentForm.find('.write').attr('data-tab', $editContentZone.data('write'));
$editContentForm.find('.preview').attr('data-tab', $editContentZone.data('preview'));
easyMDE = await createCommentEasyMDE($textarea);
};
initCompMarkupContentPreviewTab($editContentForm);
initEasyMDEImagePaste(easyMDE, $dropzone);
const saveAndRefresh = (dz, $dropzone) => {
showElem($renderContent);
hideElem($editContentZone);
const $attachments = $dropzone.find('.files').find('[name=files]').map(function () {
return $(this).val();
}).get();
$.post($editContentZone.data('update-url'), {
_csrf: csrfToken,
content: comboMarkdownEditor.value(),
context: $editContentZone.data('context'),
files: $attachments,
}, (data) => {
if (!data.content) {
$renderContent.html($('#no-content').html());
$rawContent.text('');
} else {
$renderContent.html(data.content);
$rawContent.text(comboMarkdownEditor.value());
const $saveButton = $editContentZone.find('.save.button');
$textarea.on('ce-quick-submit', () => {
$saveButton.trigger('click');
});
$editContentZone.find('.cancel.button').on('click', (e) => {
e.preventDefault();
showElem($renderContent);
hideElem($editContentZone);
if (dz) {
dz.emit('reload');
const refIssues = $renderContent.find('p .ref-issue');
attachRefIssueContextPopup(refIssues);
}
});
$saveButton.on('click', () => {
showElem($renderContent);
hideElem($editContentZone);
const $attachments = $dropzone.find('.files').find('[name=files]').map(function () {
return $(this).val();
}).get();
$.post($editContentZone.data('update-url'), {
_csrf: csrfToken,
content: $textarea.val(),
context: $editContentZone.data('context'),
files: $attachments,
}, (data) => {
if (data.length === 0 || data.content.length === 0) {
$renderContent.html($('#no-content').html());
$rawContent.text('');
} else {
$renderContent.html(data.content);
$rawContent.text($textarea.val());
const refIssues = $renderContent.find('p .ref-issue');
attachRefIssueContextPopup(refIssues);
}
const $content = $segment;
if (!$content.find('.dropzone-attachments').length) {
if (data.attachments !== '') {
$content.append(`<div class="dropzone-attachments"></div>`);
$content.find('.dropzone-attachments').replaceWith(data.attachments);
}
} else if (data.attachments === '') {
$content.find('.dropzone-attachments').remove();
} else {
const $content = $segment;
if (!$content.find('.dropzone-attachments').length) {
if (data.attachments !== '') {
$content.append(`<div class="dropzone-attachments"></div>`);
$content.find('.dropzone-attachments').replaceWith(data.attachments);
}
if (dz) {
dz.emit('submit');
dz.emit('reload');
}
initMarkupContent();
initCommentContent();
});
} else if (data.attachments === '') {
$content.find('.dropzone-attachments').remove();
} else {
$content.find('.dropzone-attachments').replaceWith(data.attachments);
}
if (dz) {
dz.emit('submit');
dz.emit('reload');
}
initMarkupContent();
initCommentContent();
});
} else { // use existing form
$textarea = $segment.find('textarea');
easyMDE = getAttachedEasyMDE($textarea);
};
if (!$editContentZone.html()) {
$editContentZone.html($('#issue-comment-editor-template').html());
comboMarkdownEditor = await initComboMarkdownEditor($editContentZone.find('.combo-markdown-editor'));
const $dropzone = $editContentZone.find('.dropzone');
const dz = await setupDropzone($dropzone);
$editContentZone.find('.cancel.button').on('click', (e) => {
e.preventDefault();
cancelAndReset(dz);
});
$editContentZone.find('.save.button').on('click', (e) => {
e.preventDefault();
saveAndRefresh(dz, $dropzone);
});
} else {
comboMarkdownEditor = getComboMarkdownEditor($editContentZone.find('.combo-markdown-editor'));
}
// Show write/preview tab and copy raw content as needed
showElem($editContentZone);
hideElem($renderContent);
if ($textarea.val().length === 0) {
$textarea.val($rawContent.text());
easyMDE.value($rawContent.text());
if (!comboMarkdownEditor.value()) {
comboMarkdownEditor.value($rawContent.text());
}
requestAnimationFrame(() => {
$textarea.focus();
easyMDE.codemirror.focus();
});
comboMarkdownEditor.focus();
}
export function initRepository() {
@ -575,7 +553,6 @@ export function initRepository() {
initRepoIssueCommentDelete();
initRepoIssueDependencyDelete();
initRepoIssueCodeCommentCancel();
initRepoIssueStatusButton();
initRepoPullRequestUpdate();
initCompReactionSelector();
@ -592,12 +569,6 @@ export function initRepository() {
const $form = $repoComparePull.find('.pullrequest-form');
showElem($form);
$form.find('textarea.edit_area').each(function() {
const easyMDE = getAttachedEasyMDE($(this));
if (easyMDE) {
easyMDE.codemirror.refresh();
}
});
});
}
@ -614,24 +585,22 @@ function initRepoIssueCommentEdit() {
const target = $(this).data('target');
const quote = $(`#${target}`).text().replace(/\n/g, '\n> ');
const content = `> ${quote}\n\n`;
let easyMDE;
let editor;
if ($(this).hasClass('quote-reply-diff')) {
const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply');
easyMDE = await handleReply($replyBtn);
editor = await handleReply($replyBtn);
} else {
// for normal issue/comment page
easyMDE = getAttachedEasyMDE($('#comment-form .edit_area'));
editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor'));
}
if (easyMDE) {
if (easyMDE.value() !== '') {
easyMDE.value(`${easyMDE.value()}\n\n${content}`);
if (editor) {
if (editor.value()) {
editor.value(`${editor.value()}\n\n${content}`);
} else {
easyMDE.value(`${content}`);
editor.value(content);
}
requestAnimationFrame(() => {
easyMDE.codemirror.focus();
easyMDE.codemirror.setCursor(easyMDE.codemirror.lineCount(), 0);
});
editor.focus();
editor.moveCursorToEnd();
}
});
}