diff options
author | wxiaoguang <wxiaoguang@gmail.com> | 2023-04-03 18:06:57 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-03 18:06:57 +0800 |
commit | 5cc0801de90d16b4d528e62de11c9b525be5d122 (patch) | |
tree | 7deaaa2ec388cd91b6b072783d2e4524ef9be263 /web_src/js/features/comp | |
parent | d67e40684f43b0eb744cad26e0265002f033dbc3 (diff) | |
download | gitea-5cc0801de90d16b4d528e62de11c9b525be5d122.tar.gz gitea-5cc0801de90d16b4d528e62de11c9b525be5d122.zip |
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>
Diffstat (limited to 'web_src/js/features/comp')
-rw-r--r-- | web_src/js/features/comp/ComboMarkdownEditor.js | 277 | ||||
-rw-r--r-- | web_src/js/features/comp/EasyMDE.js | 181 | ||||
-rw-r--r-- | web_src/js/features/comp/ImagePaste.js | 47 | ||||
-rw-r--r-- | web_src/js/features/comp/MarkupContentPreview.js | 25 |
4 files changed, 303 insertions, 227 deletions
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js new file mode 100644 index 0000000000..4905ec2341 --- /dev/null +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -0,0 +1,277 @@ +import '@github/markdown-toolbar-element'; +import {attachTribute} from '../tribute.js'; +import {hideElem, showElem} from '../../utils/dom.js'; +import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js'; +import $ from 'jquery'; +import {initMarkupContent} from '../../markup/content.js'; +import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; +import {attachRefIssueContextPopup} from '../contextpopup.js'; + +let elementIdCounter = 0; + +/** + * validate if the given textarea is non-empty. + * @param {jQuery} $textarea + * @returns {boolean} returns true if validation succeeded. + */ +export function validateTextareaNonEmpty($textarea) { + // When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation. + // The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert. + if (!$textarea.val()) { + if ($textarea.is(':visible')) { + $textarea.prop('required', true); + const $form = $textarea.parents('form'); + $form[0]?.reportValidity(); + } else { + // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places. + alert('Require non-empty content'); + } + return false; + } + return true; +} + +class ComboMarkdownEditor { + constructor(container, options = {}) { + container._giteaComboMarkdownEditor = this; + this.options = options; + this.container = container; + } + + async init() { + this.textarea = this.container.querySelector('.markdown-text-editor'); + this.textarea._giteaComboMarkdownEditor = this; + this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter)}`; + this.textarea.addEventListener('input', (e) => {this.options?.onContentChanged?.(this, e)}); + this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar'); + this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id); + + elementIdCounter++; + + this.switchToEasyMDEButton = this.container.querySelector('.markdown-switch-easymde'); + this.switchToEasyMDEButton?.addEventListener('click', async (e) => { + e.preventDefault(); + await this.switchToEasyMDE(); + }); + + await attachTribute(this.textarea, {mentions: true, emoji: true}); + + const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container'); + if (dropzoneParentContainer) { + this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone'); + initTextareaImagePaste(this.textarea, this.dropzone); + } + + this.setupTab(); + this.prepareEasyMDEToolbarActions(); + } + + setupTab() { + const $container = $(this.container); + const $tabMenu = $container.find('.tabular.menu'); + const $tabs = $tabMenu.find('> .item'); + + // Fomantic Tab requires the "data-tab" to be globally unique. + // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic. + const $tabEditor = $tabs.filter(`.item[data-tab-for="markdown-writer"]`); + const $tabPreviewer = $tabs.filter(`.item[data-tab-for="markdown-previewer"]`); + $tabEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`); + $tabPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`); + const $panelEditor = $container.find('.ui.tab[data-tab-panel="markdown-writer"]'); + const $panelPreviewer = $container.find('.ui.tab[data-tab-panel="markdown-previewer"]'); + $panelEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`); + $panelPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`); + elementIdCounter++; + + $tabs.tab(); + + this.previewUrl = $tabPreviewer.attr('data-preview-url'); + this.previewContext = $tabPreviewer.attr('data-preview-context'); + this.previewMode = this.options.previewMode ?? 'comment'; + this.previewWiki = this.options.previewWiki ?? false; + $tabPreviewer.on('click', () => { + $.post(this.previewUrl, { + _csrf: window.config.csrfToken, + mode: this.previewMode, + context: this.previewContext, + text: this.value(), + wiki: this.previewWiki, + }, (data) => { + $panelPreviewer.html(data); + initMarkupContent(); + + const refIssues = $panelPreviewer.find('p .ref-issue'); + attachRefIssueContextPopup(refIssues); + }); + }); + } + + prepareEasyMDEToolbarActions() { + this.easyMDEToolbarDefault = [ + 'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|', + 'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|', + 'unordered-list', 'ordered-list', '|', 'link', 'image', 'table', 'horizontal-rule', '|', 'clean-block', '|', + 'gitea-switch-to-textarea', + ]; + + this.easyMDEToolbarActions = { + 'gitea-checkbox-empty': { + action(e) { + const cm = e.codemirror; + cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`); + cm.focus(); + }, + className: 'fa fa-square-o', + title: 'Add Checkbox (empty)', + }, + 'gitea-checkbox-checked': { + action(e) { + const cm = e.codemirror; + cm.replaceSelection(`\n- [x] ${cm.getSelection()}`); + cm.focus(); + }, + className: 'fa fa-check-square-o', + title: 'Add Checkbox (checked)', + }, + 'gitea-switch-to-textarea': { + action: this.switchToTextarea.bind(this), + className: 'fa fa-file', + title: 'Revert to simple textarea', + }, + 'gitea-code-inline': { + action(e) { + const cm = e.codemirror; + const selection = cm.getSelection(); + cm.replaceSelection(`\`${selection}\``); + if (!selection) { + const cursorPos = cm.getCursor(); + cm.setCursor(cursorPos.line, cursorPos.ch - 1); + } + cm.focus(); + }, + className: 'fa fa-angle-right', + title: 'Add Inline Code', + } + }; + } + + parseEasyMDEToolbar(actions) { + const processed = []; + for (const action of actions) { + if (action.startsWith('gitea-')) { + const giteaAction = this.easyMDEToolbarActions[action]; + if (!giteaAction) throw new Error(`Unknown EasyMDE toolbar action ${action}`); + processed.push(giteaAction); + } else { + processed.push(action); + } + } + return processed; + } + + async switchToTextarea() { + showElem(this.textareaMarkdownToolbar); + if (this.easyMDE) { + this.easyMDE.toTextArea(); + this.easyMDE = null; + } + } + + async switchToEasyMDE() { + // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles. + const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde'); + const easyMDEOpt = { + autoDownloadFontAwesome: false, + element: this.textarea, + forceSync: true, + renderingConfig: {singleLineBreaks: false}, + indentWithTabs: false, + tabSize: 4, + spellChecker: false, + inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable + nativeSpellcheck: true, + ...this.options.easyMDEOptions, + }; + easyMDEOpt.toolbar = this.parseEasyMDEToolbar(easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault); + + this.easyMDE = new EasyMDE(easyMDEOpt); + this.easyMDE.codemirror.on('change', (...args) => {this.options?.onContentChanged?.(this, ...args)}); + this.easyMDE.codemirror.setOption('extraKeys', { + 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), + 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), + Enter: (cm) => { + const tributeContainer = document.querySelector('.tribute-container'); + if (!tributeContainer || tributeContainer.style.display === 'none') { + cm.execCommand('newlineAndIndent'); + } + }, + Up: (cm) => { + const tributeContainer = document.querySelector('.tribute-container'); + if (!tributeContainer || tributeContainer.style.display === 'none') { + return cm.execCommand('goLineUp'); + } + }, + Down: (cm) => { + const tributeContainer = document.querySelector('.tribute-container'); + if (!tributeContainer || tributeContainer.style.display === 'none') { + return cm.execCommand('goLineDown'); + } + }, + }); + await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true}); + initEasyMDEImagePaste(this.easyMDE, this.dropzone); + hideElem(this.textareaMarkdownToolbar); + } + + value(v = undefined) { + if (v === undefined) { + if (this.easyMDE) { + return this.easyMDE.value(); + } + return this.textarea.value; + } + + if (this.easyMDE) { + this.easyMDE.value(v); + } else { + this.textarea.value = v; + } + } + + focus() { + if (this.easyMDE) { + this.easyMDE.codemirror.focus(); + } else { + this.textarea.focus(); + } + } + + moveCursorToEnd() { + this.textarea.focus(); + this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length); + if (this.easyMDE) { + this.easyMDE.codemirror.focus(); + this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0); + } + } +} + +export function getComboMarkdownEditor(el) { + if (el instanceof $) el = el[0]; + return el?._giteaComboMarkdownEditor; +} + +export async function initComboMarkdownEditor(container, options = {}) { + if (container instanceof $) { + if (container.length !== 1) { + throw new Error('initComboMarkdownEditor: container must be a single element'); + } + container = container[0]; + } + if (!container) { + throw new Error('initComboMarkdownEditor: container is null'); + } + const editor = new ComboMarkdownEditor(container, options); + await editor.init(); + return editor; +} diff --git a/web_src/js/features/comp/EasyMDE.js b/web_src/js/features/comp/EasyMDE.js deleted file mode 100644 index 2979627b00..0000000000 --- a/web_src/js/features/comp/EasyMDE.js +++ /dev/null @@ -1,181 +0,0 @@ -import $ from 'jquery'; -import {attachTribute} from '../tribute.js'; -import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; - -/** - * @returns {EasyMDE} - */ -export async function importEasyMDE() { - // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can - // not overwrite the default styles. - const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde'); - return EasyMDE; -} - -/** - * create an EasyMDE editor for comment - * @param textarea jQuery or HTMLElement - * @param easyMDEOptions the options for EasyMDE - * @returns {null|EasyMDE} - */ -export async function createCommentEasyMDE(textarea, easyMDEOptions = {}) { - if (textarea instanceof $) { - textarea = textarea[0]; - } - if (!textarea) { - return null; - } - - const EasyMDE = await importEasyMDE(); - - const easyMDE = new EasyMDE({ - autoDownloadFontAwesome: false, - element: textarea, - forceSync: true, - renderingConfig: { - singleLineBreaks: false, - }, - indentWithTabs: false, - tabSize: 4, - spellChecker: false, - inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable - nativeSpellcheck: true, - toolbar: ['bold', 'italic', 'strikethrough', '|', - 'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|', - 'code', 'quote', '|', { - name: 'checkbox-empty', - action(e) { - const cm = e.codemirror; - cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`); - cm.focus(); - }, - className: 'fa fa-square-o', - title: 'Add Checkbox (empty)', - }, - { - name: 'checkbox-checked', - action(e) { - const cm = e.codemirror; - cm.replaceSelection(`\n- [x] ${cm.getSelection()}`); - cm.focus(); - }, - className: 'fa fa-check-square-o', - title: 'Add Checkbox (checked)', - }, '|', - 'unordered-list', 'ordered-list', '|', - 'link', 'image', 'table', 'horizontal-rule', '|', - 'clean-block', '|', - { - name: 'revert-to-textarea', - action(e) { - e.toTextArea(); - }, - className: 'fa fa-file', - title: 'Revert to simple textarea', - }, - ], ...easyMDEOptions}); - - const inputField = easyMDE.codemirror.getInputField(); - - easyMDE.codemirror.on('change', (...args) => { - easyMDEOptions?.onChange?.(...args); - }); - easyMDE.codemirror.setOption('extraKeys', { - 'Cmd-Enter': codeMirrorQuickSubmit, - 'Ctrl-Enter': codeMirrorQuickSubmit, - Enter: (cm) => { - const tributeContainer = document.querySelector('.tribute-container'); - if (!tributeContainer || tributeContainer.style.display === 'none') { - cm.execCommand('newlineAndIndent'); - } - }, - Backspace: (cm) => { - if (cm.getInputField().trigger) { - cm.getInputField().trigger('input'); - } - cm.execCommand('delCharBefore'); - }, - Up: (cm) => { - const tributeContainer = document.querySelector('.tribute-container'); - if (!tributeContainer || tributeContainer.style.display === 'none') { - return cm.execCommand('goLineUp'); - } - }, - Down: (cm) => { - const tributeContainer = document.querySelector('.tribute-container'); - if (!tributeContainer || tributeContainer.style.display === 'none') { - return cm.execCommand('goLineDown'); - } - }, - }); - await attachTribute(inputField, {mentions: true, emoji: true}); - attachEasyMDEToElements(easyMDE); - return easyMDE; -} - -/** - * attach the EasyMDE object to its input elements (InputField, TextArea) - * @param {EasyMDE} easyMDE - */ -export function attachEasyMDEToElements(easyMDE) { - // TODO: that's the only way we can do now to attach the EasyMDE object to a HTMLElement - - // InputField is used by CodeMirror to accept user input - const inputField = easyMDE.codemirror.getInputField(); - inputField._data_easyMDE = easyMDE; - - // TextArea is the real textarea element in the form - const textArea = easyMDE.codemirror.getTextArea(); - textArea._data_easyMDE = easyMDE; -} - - -/** - * get the attached EasyMDE editor created by createCommentEasyMDE - * @param el jQuery or HTMLElement - * @returns {null|EasyMDE} - */ -export function getAttachedEasyMDE(el) { - if (el instanceof $) { - el = el[0]; - } - if (!el) { - return null; - } - return el._data_easyMDE; -} - -/** - * validate if the given EasyMDE textarea is is non-empty. - * @param {jQuery} $textarea - * @returns {boolean} returns true if validation succeeded. - */ -export function validateTextareaNonEmpty($textarea) { - const $mdeInputField = $(getAttachedEasyMDE($textarea).codemirror.getInputField()); - // The original edit area HTML element is hidden and replaced by the - // SimpleMDE/EasyMDE editor, breaking HTML5 input validation if the text area is empty. - // This is a workaround for this upstream bug. - // See https://github.com/sparksuite/simplemde-markdown-editor/issues/324 - if (!$textarea.val()) { - $mdeInputField.prop('required', true); - const $form = $textarea.parents('form'); - if (!$form.length) { - // this should never happen. we put a alert here in case the textarea would be forgotten to be put in a form - alert('Require non-empty content'); - } else { - $form[0].reportValidity(); - } - return false; - } - $mdeInputField.prop('required', false); - return true; -} - -/** - * there is no guarantee that the CodeMirror object is inside the same form as the textarea, - * so can not call handleGlobalEnterQuickSubmit directly. - * @param {CodeMirror.EditorFromTextArea} codeMirror - */ -export function codeMirrorQuickSubmit(codeMirror) { - handleGlobalEnterQuickSubmit(codeMirror.getTextArea()); -} diff --git a/web_src/js/features/comp/ImagePaste.js b/web_src/js/features/comp/ImagePaste.js index da41e7611a..9145b24062 100644 --- a/web_src/js/features/comp/ImagePaste.js +++ b/web_src/js/features/comp/ImagePaste.js @@ -88,38 +88,43 @@ class CodeMirrorEditor { } -export function initEasyMDEImagePaste(easyMDE, $dropzone) { +const uploadClipboardImage = async (editor, dropzone, e) => { + const $dropzone = $(dropzone); const uploadUrl = $dropzone.attr('data-upload-url'); const $files = $dropzone.find('.files'); if (!uploadUrl || !$files.length) return; - const uploadClipboardImage = async (editor, e) => { - const pastedImages = clipboardPastedImages(e); - if (!pastedImages || pastedImages.length === 0) { - return; - } - e.preventDefault(); - e.stopPropagation(); + const pastedImages = clipboardPastedImages(e); + if (!pastedImages || pastedImages.length === 0) { + return; + } + e.preventDefault(); + e.stopPropagation(); - for (const img of pastedImages) { - const name = img.name.slice(0, img.name.lastIndexOf('.')); + for (const img of pastedImages) { + const name = img.name.slice(0, img.name.lastIndexOf('.')); - const placeholder = `![${name}](uploading ...)`; - editor.insertPlaceholder(placeholder); - const data = await uploadFile(img, uploadUrl); - editor.replacePlaceholder(placeholder, `![${name}](/attachments/${data.uuid})`); + const placeholder = `![${name}](uploading ...)`; + editor.insertPlaceholder(placeholder); + const data = await uploadFile(img, uploadUrl); + editor.replacePlaceholder(placeholder, `![${name}](/attachments/${data.uuid})`); - const $input = $(`<input name="files" type="hidden">`).attr('id', data.uuid).val(data.uuid); - $files.append($input); - } - }; + const $input = $(`<input name="files" type="hidden">`).attr('id', data.uuid).val(data.uuid); + $files.append($input); + } +}; +export function initEasyMDEImagePaste(easyMDE, dropzone) { + if (!dropzone) return; easyMDE.codemirror.on('paste', async (_, e) => { - return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), e); + return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e); }); +} - $(easyMDE.element).on('paste', async (e) => { - return uploadClipboardImage(new TextareaEditor(easyMDE.element), e.originalEvent); +export function initTextareaImagePaste(textarea, dropzone) { + if (!dropzone) return; + $(textarea).on('paste', async (e) => { + return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e.originalEvent); }); } diff --git a/web_src/js/features/comp/MarkupContentPreview.js b/web_src/js/features/comp/MarkupContentPreview.js deleted file mode 100644 index a32bf30184..0000000000 --- a/web_src/js/features/comp/MarkupContentPreview.js +++ /dev/null @@ -1,25 +0,0 @@ -import $ from 'jquery'; -import {initMarkupContent} from '../../markup/content.js'; -import {attachRefIssueContextPopup} from '../contextpopup.js'; - -const {csrfToken} = window.config; - -export function initCompMarkupContentPreviewTab($form) { - const $tabMenu = $form.find('.tabular.menu'); - $tabMenu.find('.item').tab(); - $tabMenu.find(`.item[data-tab="${$tabMenu.data('preview')}"]`).on('click', function () { - const $this = $(this); - $.post($this.data('url'), { - _csrf: csrfToken, - mode: 'comment', - context: $this.data('context'), - text: $form.find(`.tab[data-tab="${$tabMenu.data('write')}"] textarea`).val() - }, (data) => { - const $previewPanel = $form.find(`.tab[data-tab="${$tabMenu.data('preview')}"]`); - $previewPanel.html(data); - const refIssues = $previewPanel.find('p .ref-issue'); - attachRefIssueContextPopup(refIssues); - initMarkupContent(); - }); - }); -} |