mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 10:56:10 +01:00 
			
		
		
		
	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>     </details> --------- Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
		| @@ -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(); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user