aboutsummaryrefslogtreecommitdiffstats
path: root/web_src/js/features/comp
diff options
context:
space:
mode:
authorwxiaoguang <wxiaoguang@gmail.com>2023-04-03 18:06:57 +0800
committerGitHub <noreply@github.com>2023-04-03 18:06:57 +0800
commit5cc0801de90d16b4d528e62de11c9b525be5d122 (patch)
tree7deaaa2ec388cd91b6b072783d2e4524ef9be263 /web_src/js/features/comp
parentd67e40684f43b0eb744cad26e0265002f033dbc3 (diff)
downloadgitea-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.js277
-rw-r--r--web_src/js/features/comp/EasyMDE.js181
-rw-r--r--web_src/js/features/comp/ImagePaste.js47
-rw-r--r--web_src/js/features/comp/MarkupContentPreview.js25
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();
- });
- });
-}