aboutsummaryrefslogtreecommitdiffstats
path: root/web_src
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
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')
-rw-r--r--web_src/css/editor-markdown.css25
-rw-r--r--web_src/css/editor.css1
-rw-r--r--web_src/css/index.css1
-rw-r--r--web_src/css/repository.css4
-rw-r--r--web_src/css/review.css5
-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
-rw-r--r--web_src/js/features/contextpopup.js13
-rw-r--r--web_src/js/features/repo-diff.js2
-rw-r--r--web_src/js/features/repo-issue.js74
-rw-r--r--web_src/js/features/repo-legacy.js331
-rw-r--r--web_src/js/features/repo-release.js17
-rw-r--r--web_src/js/features/repo-wiki.js220
-rw-r--r--web_src/js/features/tribute.js33
-rw-r--r--web_src/js/index.js3
-rw-r--r--web_src/js/utils.js5
-rw-r--r--web_src/js/utils.test.js6
19 files changed, 562 insertions, 708 deletions
diff --git a/web_src/css/editor-markdown.css b/web_src/css/editor-markdown.css
new file mode 100644
index 0000000000..31ffeb06d0
--- /dev/null
+++ b/web_src/css/editor-markdown.css
@@ -0,0 +1,25 @@
+.combo-markdown-editor {
+ width: 100%;
+}
+
+.combo-markdown-editor markdown-toolbar {
+ cursor: default;
+ display: block;
+ padding-bottom: 10px;
+}
+
+.combo-markdown-editor .markdown-toolbar-group {
+ display: inline-block;
+}
+
+.combo-markdown-editor .markdown-toolbar-button {
+ user-select: none;
+ padding: 5px;
+ cursor: pointer;
+}
+
+.combo-markdown-editor .markdown-text-editor {
+ display: block;
+ width: 100%;
+ height: 200px;
+}
diff --git a/web_src/css/editor.css b/web_src/css/editor.css
index d3f9edeb2d..ba35036e4f 100644
--- a/web_src/css/editor.css
+++ b/web_src/css/editor.css
@@ -13,7 +13,6 @@
}
.editor-toolbar {
- max-width: calc(100vw - 80px);
border-color: var(--color-secondary);
}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index dd5f739379..e8d4e290d0 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -29,6 +29,7 @@
@import "./form.css";
@import "./repository.css";
@import "./editor.css";
+@import "./editor-markdown.css";
@import "./organization.css";
@import "./user.css";
@import "./dashboard.css";
diff --git a/web_src/css/repository.css b/web_src/css/repository.css
index 27d6a51cdd..fb5c75b73c 100644
--- a/web_src/css/repository.css
+++ b/web_src/css/repository.css
@@ -2116,10 +2116,6 @@
height: 48px;
}
-.repository.wiki.new .ui.attached.tabular.menu.previewtabs {
- margin-bottom: 15px;
-}
-
.repository.wiki.view > .markup {
padding: 15px 30px;
}
diff --git a/web_src/css/review.css b/web_src/css/review.css
index 3deb2192fc..913a7e9df2 100644
--- a/web_src/css/review.css
+++ b/web_src/css/review.css
@@ -248,6 +248,11 @@ a.blob-excerpt:hover {
}
}
+.review-box-panel .combo-markdown-editor textarea {
+ width: 730px;
+ max-width: calc(100vw - 70px);
+}
+
#review-box {
position: relative;
}
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();
- });
- });
-}
diff --git a/web_src/js/features/contextpopup.js b/web_src/js/features/contextpopup.js
index 7b37035547..5c5733b35a 100644
--- a/web_src/js/features/contextpopup.js
+++ b/web_src/js/features/contextpopup.js
@@ -10,17 +10,16 @@ export function initContextPopups() {
}
export function attachRefIssueContextPopup(refIssues) {
- if (!refIssues.length) return;
- refIssues.each(function () {
- if ($(this).hasClass('ref-external-issue')) {
+ for (const refIssue of refIssues) {
+ if (refIssue.classList.contains('ref-external-issue')) {
return;
}
- const {owner, repo, index} = parseIssueHref($(this).attr('href'));
+ const {owner, repo, index} = parseIssueHref(refIssue.getAttribute('href'));
if (!owner) return;
const el = document.createElement('div');
- this.parentNode.insertBefore(el, this.nextSibling);
+ refIssue.parentNode.insertBefore(el, refIssue.nextSibling);
const view = createApp(ContextPopup);
@@ -31,7 +30,7 @@ export function attachRefIssueContextPopup(refIssues) {
el.textContent = 'ContextPopup failed to load';
}
- createTippy(this, {
+ createTippy(refIssue, {
content: el,
placement: 'top-start',
interactive: true,
@@ -40,5 +39,5 @@ export function attachRefIssueContextPopup(refIssues) {
el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}}));
}
});
- });
+ }
}
diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.js
index 458f11c6f2..df66db7f6c 100644
--- a/web_src/js/features/repo-diff.js
+++ b/web_src/js/features/repo-diff.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
import {initCompReactionSelector} from './comp/ReactionSelector.js';
import {initRepoIssueContentHistory} from './repo-issue-content.js';
-import {validateTextareaNonEmpty} from './comp/EasyMDE.js';
import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles} from './pull-view-file.js';
+import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js';
const {csrfToken} = window.config;
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index 3f1b73d91e..03c9977f49 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -1,12 +1,9 @@
import $ from 'jquery';
import {htmlEscape} from 'escape-goat';
-import {attachTribute} from './tribute.js';
-import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js';
-import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
-import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
import {showTemporaryTooltip, createTippy} from '../modules/tippy.js';
import {hideElem, showElem, toggleElem} from '../utils/dom.js';
import {setFileFolding} from './file-fold.js';
+import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
const {appSubUrl, csrfToken} = window.config;
@@ -223,21 +220,6 @@ export function initRepoIssueCodeCommentCancel() {
});
}
-export function initRepoIssueStatusButton() {
- // Change status
- const $statusButton = $('#status-button');
- $('#comment-form textarea').on('keyup', function () {
- const easyMDE = getAttachedEasyMDE(this);
- const value = easyMDE?.value() || $(this).val();
- $statusButton.text($statusButton.data(value.length === 0 ? 'status' : 'status-and-comment'));
- });
- $statusButton.on('click', (e) => {
- e.preventDefault();
- $('#status').val($statusButton.data('status-val'));
- $('#comment-form').trigger('submit');
- });
-}
-
export function initRepoPullRequestUpdate() {
// Pull Request update button
const $pullUpdateButton = $('.update-button > button');
@@ -402,35 +384,18 @@ export function initRepoIssueComments() {
});
}
-
-function assignMenuAttributes(menu) {
- const id = Math.floor(Math.random() * Math.floor(1000000));
- menu.attr('data-write', menu.attr('data-write') + id);
- menu.attr('data-preview', menu.attr('data-preview') + id);
- menu.find('.item').each(function () {
- const tab = $(this).attr('data-tab') + id;
- $(this).attr('data-tab', tab);
- });
- menu.parent().find("*[data-tab='write']").attr('data-tab', `write${id}`);
- menu.parent().find("*[data-tab='preview']").attr('data-tab', `preview${id}`);
- initCompMarkupContentPreviewTab(menu.parent('.form'));
- return id;
-}
-
export async function handleReply($el) {
hideElem($el);
const form = $el.closest('.comment-code-cloud').find('.comment-form');
form.removeClass('gt-hidden');
+
const $textarea = form.find('textarea');
- let easyMDE = getAttachedEasyMDE($textarea);
- if (!easyMDE) {
- await attachTribute($textarea.get(), {mentions: true, emoji: true});
- easyMDE = await createCommentEasyMDE($textarea);
+ let editor = getComboMarkdownEditor($textarea);
+ if (!editor) {
+ editor = await initComboMarkdownEditor(form.find('.combo-markdown-editor'));
}
- $textarea.focus();
- easyMDE.codemirror.focus();
- assignMenuAttributes(form.find('.menu'));
- return easyMDE;
+ editor.focus();
+ return editor;
}
export function initRepoPullRequestReview() {
@@ -494,14 +459,7 @@ export function initRepoPullRequestReview() {
const $reviewBox = $('.review-box-panel');
if ($reviewBox.length === 1) {
- (async () => {
- // the editor's height is too large in some cases, and the panel cannot be scrolled with page now because there is `.repository .diff-detail-box.sticky { position: sticky; }`
- // the temporary solution is to make the editor's height smaller (about 4 lines). GitHub also only show 4 lines for default. We can improve the UI (including Dropzone area) in future
- // EasyMDE's options can not handle minHeight & maxHeight together correctly, we have to set max-height for .CodeMirror-scroll in CSS.
- const $reviewTextarea = $reviewBox.find('textarea');
- const easyMDE = await createCommentEasyMDE($reviewTextarea, {minHeight: '80px'});
- initEasyMDEImagePaste(easyMDE, $reviewBox.find('.dropzone'));
- })();
+ const _promise = initComboMarkdownEditor($reviewBox.find('.combo-markdown-editor'));
}
// The following part is only for diff views
@@ -565,20 +523,16 @@ export function initRepoPullRequestReview() {
}
const td = ntr.find(`.add-comment-${side}`);
- let commentCloud = td.find('.comment-code-cloud');
+ const commentCloud = td.find('.comment-code-cloud');
if (commentCloud.length === 0 && !ntr.find('button[name="pending_review"]').length) {
- const data = await $.get($(this).closest('[data-new-comment-url]').data('new-comment-url'));
- td.html(data);
- commentCloud = td.find('.comment-code-cloud');
- assignMenuAttributes(commentCloud.find('.menu'));
+ const html = await $.get($(this).closest('[data-new-comment-url]').attr('data-new-comment-url'));
+ td.html(html);
td.find("input[name='line']").val(idx);
td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
td.find("input[name='path']").val(path);
- const $textarea = commentCloud.find('textarea');
- await attachTribute($textarea.get(), {mentions: true, emoji: true});
- const easyMDE = await createCommentEasyMDE($textarea);
- $textarea.focus();
- easyMDE.codemirror.focus();
+
+ const editor = await initComboMarkdownEditor(td.find('.combo-markdown-editor'));
+ editor.focus();
}
});
}
diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js
index 3689c34272..2e39d3762f 100644
--- a/web_src/js/features/repo-legacy.js
+++ b/web_src/js/features/repo-legacy.js
@@ -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 dz;
- const $dropzone = $editContentZone.find('.dropzone');
- if ($dropzone.length === 1) {
- $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);
- });
- 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;
+ let comboMarkdownEditor;
+
+ const setupDropzone = async ($dropzone) => {
+ if ($dropzone.length === 0) return null;
+ $dropzone.data('saved', false);
+
+ 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('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 $saveButton = $editContentZone.find('.save.button');
- $textarea.on('ce-quick-submit', () => {
- $saveButton.trigger('click');
- });
+ };
+
+ 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());
- $editContentZone.find('.cancel.button').on('click', (e) => {
- e.preventDefault();
- showElem($renderContent);
- hideElem($editContentZone);
+ 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 {
+ $content.find('.dropzone-attachments').replaceWith(data.attachments);
+ }
if (dz) {
+ dz.emit('submit');
dz.emit('reload');
}
+ initMarkupContent();
+ initCommentContent();
});
+ };
- $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 {
- $content.find('.dropzone-attachments').replaceWith(data.attachments);
- }
- if (dz) {
- dz.emit('submit');
- dz.emit('reload');
- }
- initMarkupContent();
- initCommentContent();
- });
+ 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 { // use existing form
- $textarea = $segment.find('textarea');
- easyMDE = getAttachedEasyMDE($textarea);
+ } 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();
}
});
}
diff --git a/web_src/js/features/repo-release.js b/web_src/js/features/repo-release.js
index a230d7765e..5cc6f1e3cd 100644
--- a/web_src/js/features/repo-release.js
+++ b/web_src/js/features/repo-release.js
@@ -1,9 +1,6 @@
import $ from 'jquery';
-import {attachTribute} from './tribute.js';
-import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
-import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
-import {createCommentEasyMDE} from './comp/EasyMDE.js';
import {hideElem, showElem} from '../utils/dom.js';
+import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
export function initRepoRelease() {
$(document).on('click', '.remove-rel-attach', function() {
@@ -51,17 +48,9 @@ function initTagNameEditor() {
}
function initRepoReleaseEditor() {
- const $editor = $('.repository.new.release .content-editor');
+ const $editor = $('.repository.new.release .combo-markdown-editor');
if ($editor.length === 0) {
return;
}
-
- (async () => {
- const $textarea = $editor.find('textarea');
- await attachTribute($textarea.get(), {mentions: true, emoji: true});
- const easyMDE = await createCommentEasyMDE($textarea);
- initCompMarkupContentPreviewTab($editor);
- const $dropzone = $editor.parent().find('.dropzone');
- initEasyMDEImagePaste(easyMDE, $dropzone);
- })();
+ const _promise = initComboMarkdownEditor($editor);
}
diff --git a/web_src/js/features/repo-wiki.js b/web_src/js/features/repo-wiki.js
index 4555b32e5f..a48f63dcb1 100644
--- a/web_src/js/features/repo-wiki.js
+++ b/web_src/js/features/repo-wiki.js
@@ -1,194 +1,68 @@
import $ from 'jquery';
import {initMarkupContent} from '../markup/content.js';
-import {attachEasyMDEToElements, codeMirrorQuickSubmit, importEasyMDE, validateTextareaNonEmpty} from './comp/EasyMDE.js';
-import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
+import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
const {csrfToken} = window.config;
async function initRepoWikiFormEditor() {
- const $editArea = $('.repository.wiki textarea#edit_area');
+ const $editArea = $('.repository.wiki .combo-markdown-editor textarea');
if (!$editArea.length) return;
- let sideBySideChanges = 0;
- let sideBySideTimeout = null;
- let hasEasyMDE = true;
-
const $form = $('.repository.wiki.new .ui.form');
- const EasyMDE = await importEasyMDE();
- const easyMDE = new EasyMDE({
- autoDownloadFontAwesome: false,
- element: $editArea[0],
- forceSync: true,
- previewRender(plainText, preview) { // Async method
- // FIXME: still send render request when return back to edit mode
- const render = function () {
- sideBySideChanges = 0;
- if (sideBySideTimeout !== null) {
- clearTimeout(sideBySideTimeout);
- sideBySideTimeout = null;
- }
- $.post($editArea.data('url'), {
- _csrf: csrfToken,
- mode: 'gfm',
- context: $editArea.data('context'),
- text: plainText,
- wiki: true
- }, (data) => {
- preview.innerHTML = `<div class="markup ui segment">${data}</div>`;
- initMarkupContent();
- });
- };
+ const $editorContainer = $form.find('.combo-markdown-editor');
+ let editor;
- setTimeout(() => {
- if (!easyMDE.isSideBySideActive()) {
- render();
- } else {
- // delay preview by keystroke counting
- sideBySideChanges++;
- if (sideBySideChanges > 10) {
- render();
- }
- // or delay preview by timeout
- if (sideBySideTimeout !== null) {
- clearTimeout(sideBySideTimeout);
- sideBySideTimeout = null;
- }
- sideBySideTimeout = setTimeout(render, 600);
- }
- }, 0);
- if (!easyMDE.isSideBySideActive()) {
- return 'Loading...';
- }
- return preview.innerHTML;
- },
- 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', '|',
- {
- name: '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',
- }, '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', 'preview', 'fullscreen', 'side-by-side', '|',
- {
- name: 'revert-to-textarea',
- action(e) {
- e.toTextArea();
- hasEasyMDE = false;
- const $root = $form.find('.field.content');
- const loading = $root.data('loading');
- $root.append(`<div class="ui bottom tab markup" data-tab="preview">${loading}</div>`);
- initCompMarkupContentPreviewTab($form);
- },
- className: 'fa fa-file',
- title: 'Revert to simple textarea',
- },
- ]
- });
+ let renderRequesting = false;
+ let lastContent;
+ const renderEasyMDEPreview = function () {
+ if (renderRequesting) return;
- easyMDE.codemirror.setOption('extraKeys', {
- 'Cmd-Enter': codeMirrorQuickSubmit,
- 'Ctrl-Enter': codeMirrorQuickSubmit,
- });
+ const $previewFull = $editorContainer.find('.EasyMDEContainer .editor-preview-active');
+ const $previewSide = $editorContainer.find('.EasyMDEContainer .editor-preview-active-side');
+ const $previewTarget = $previewSide.length ? $previewSide : $previewFull;
+ const newContent = $editArea.val();
+ if (editor && $previewTarget.length && lastContent !== newContent) {
+ renderRequesting = true;
+ $.post(editor.previewUrl, {
+ _csrf: csrfToken,
+ mode: editor.previewMode,
+ context: editor.previewContext,
+ text: newContent,
+ wiki: editor.previewWiki,
+ }).done((data) => {
+ lastContent = newContent;
+ $previewTarget.html(`<div class="markup ui segment">${data}</div>`);
+ initMarkupContent();
+ }).always(() => {
+ renderRequesting = false;
+ setTimeout(renderEasyMDEPreview, 1000);
+ });
+ } else {
+ setTimeout(renderEasyMDEPreview, 1000);
+ }
+ };
+ renderEasyMDEPreview();
- attachEasyMDEToElements(easyMDE);
+ editor = await initComboMarkdownEditor($editorContainer, {
+ previewMode: 'gfm',
+ previewWiki: true,
+ easyMDEOptions: {
+ previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render
+ toolbar: ['bold', 'italic', 'strikethrough', '|',
+ 'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
+ 'gitea-code-inline', 'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|',
+ 'unordered-list', 'ordered-list', '|',
+ 'link', 'image', 'table', 'horizontal-rule', '|',
+ 'clean-block', 'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea'
+ ],
+ },
+ });
$form.on('submit', () => {
if (!validateTextareaNonEmpty($editArea)) {
return false;
}
});
-
- setTimeout(() => {
- const $bEdit = $('.repository.wiki.new .previewtabs a[data-tab="write"]');
- const $bPrev = $('.repository.wiki.new .previewtabs a[data-tab="preview"]');
- const $toolbar = $('.editor-toolbar');
- const $bPreview = $('.editor-toolbar button.preview');
- const $bSideBySide = $('.editor-toolbar a.fa-columns');
- $bEdit.on('click', (e) => {
- if (!hasEasyMDE) {
- return false;
- }
- e.stopImmediatePropagation();
- if ($toolbar.hasClass('disabled-for-preview')) {
- $bPreview.trigger('click');
- }
-
- return false;
- });
- $bPrev.on('click', (e) => {
- if (!hasEasyMDE) {
- return false;
- }
- e.stopImmediatePropagation();
- if (!$toolbar.hasClass('disabled-for-preview')) {
- $bPreview.trigger('click');
- }
- return false;
- });
- $bPreview.on('click', () => {
- setTimeout(() => {
- if ($toolbar.hasClass('disabled-for-preview')) {
- if ($bEdit.hasClass('active')) {
- $bEdit.removeClass('active');
- }
- if (!$bPrev.hasClass('active')) {
- $bPrev.addClass('active');
- }
- } else {
- if (!$bEdit.hasClass('active')) {
- $bEdit.addClass('active');
- }
- if ($bPrev.hasClass('active')) {
- $bPrev.removeClass('active');
- }
- }
- }, 0);
-
- return false;
- });
- $bSideBySide.on('click', () => {
- sideBySideChanges = 10;
- });
- }, 0);
}
export function initRepoWikiForm() {
diff --git a/web_src/js/features/tribute.js b/web_src/js/features/tribute.js
index 94f3512a2e..e77ba29950 100644
--- a/web_src/js/features/tribute.js
+++ b/web_src/js/features/tribute.js
@@ -1,11 +1,10 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.js';
-import {uniq} from '../utils.js';
import {htmlEscape} from 'escape-goat';
function makeCollections({mentions, emoji}) {
const collections = [];
- if (mentions) {
+ if (emoji) {
collections.push({
trigger: ':',
requireLeadingSpace: true,
@@ -30,14 +29,14 @@ function makeCollections({mentions, emoji}) {
});
}
- if (emoji) {
+ if (mentions) {
collections.push({
values: window.config.tributeValues,
requireLeadingSpace: true,
menuItemTemplate: (item) => {
return `
<div class="tribute-item">
- <img src="${htmlEscape(item.original.avatar)}"/>
+ <img src="${htmlEscape(item.original.avatar)}" class="gt-mr-3"/>
<span class="name">${htmlEscape(item.original.name)}</span>
${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
</div>
@@ -49,30 +48,10 @@ function makeCollections({mentions, emoji}) {
return collections;
}
-export async function attachTribute(elementOrNodeList, {mentions, emoji} = {}) {
- if (!window.config.requireTribute || !elementOrNodeList) return;
- const nodes = Array.from('length' in elementOrNodeList ? elementOrNodeList : [elementOrNodeList]);
- if (!nodes.length) return;
-
- const mentionNodes = nodes.filter((node) => {
- return mentions || node.id === 'content';
- });
- const emojiNodes = nodes.filter((node) => {
- return emoji || node.id === 'content' || node.classList.contains('emoji-input');
- });
- const uniqueNodes = uniq([...mentionNodes, ...emojiNodes]);
- if (!uniqueNodes.length) return;
-
+export async function attachTribute(element, {mentions, emoji} = {}) {
const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
-
- const collections = makeCollections({
- mentions: mentions || mentionNodes.length > 0,
- emoji: emoji || emojiNodes.length > 0,
- });
-
+ const collections = makeCollections({mentions, emoji});
const tribute = new Tribute({collection: collections, noMatchTemplate: ''});
- for (const node of uniqueNodes) {
- tribute.attach(node);
- }
+ tribute.attach(element);
return tribute;
}
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 839289e9d2..e727acfa06 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -4,7 +4,6 @@ import './bootstrap.js';
import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
import {initDashboardRepoList} from './components/DashboardRepoList.vue';
-import {attachTribute} from './features/tribute.js';
import {initGlobalCopyToClipboardListener} from './features/clipboard.js';
import {initContextPopups} from './features/contextpopup.js';
import {initRepoGraphGit} from './features/repo-graph.js';
@@ -110,8 +109,6 @@ onDomReady(() => {
initGlobalFormDirtyLeaveConfirm();
initGlobalLinkActions();
- attachTribute(document.querySelectorAll('#content, .emoji-input'));
-
initCommonIssue();
initCommonOrganization();
diff --git a/web_src/js/utils.js b/web_src/js/utils.js
index b3ffbf2988..e72e55dc65 100644
--- a/web_src/js/utils.js
+++ b/web_src/js/utils.js
@@ -30,11 +30,6 @@ export function isDarkTheme() {
return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true';
}
-// removes duplicate elements in an array
-export function uniq(arr) {
- return Array.from(new Set(arr));
-}
-
// strip <tags> from a string
export function stripTags(text) {
return text.replace(/<[^>]*>?/gm, '');
diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js
index 306acd34af..46fbb28de4 100644
--- a/web_src/js/utils.test.js
+++ b/web_src/js/utils.test.js
@@ -1,6 +1,6 @@
import {expect, test} from 'vitest';
import {
- basename, extname, isObject, uniq, stripTags, joinPaths, parseIssueHref,
+ basename, extname, isObject, stripTags, joinPaths, parseIssueHref,
prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI,
toAbsoluteUrl,
} from './utils.js';
@@ -62,10 +62,6 @@ test('isObject', () => {
expect(isObject([])).toBeFalsy();
});
-test('uniq', () => {
- expect(uniq([1, 1, 1, 2])).toEqual([1, 2]);
-});
-
test('stripTags', () => {
expect(stripTags('<a>test</a>')).toEqual('test');
});