From c4303efc23ea19f16ee826809f43888ee4583ebb Mon Sep 17 00:00:00 2001 From: yp05327 <576951401@qq.com> Date: Tue, 9 May 2023 07:22:52 +0900 Subject: [PATCH] Support markdown editor for issue template (#24400) Fixes #24398 Task: - [x] Reusing "textarea" like GitHub seems more friendly to users. - [x] ^V image pasting and file uploading handling.
screenshots ![image](https://user-images.githubusercontent.com/18380374/235418877-00090552-ebda-411c-8e39-b47246bc8746.png) ![image](https://user-images.githubusercontent.com/18380374/235419073-dc33cad7-7626-4bce-9161-eb205c7384b5.png) Display only one markdown editor: ![image](https://user-images.githubusercontent.com/18380374/235419098-ee21386d-2b2d-432e-bdb2-18646cc031e7.png) Support file upload and ^V image pasting ![image](https://user-images.githubusercontent.com/18380374/235419364-7b390fa4-da56-437d-b55e-3847fbc049e7.png)
--------- Co-authored-by: wxiaoguang Co-authored-by: silverwind --- templates/repo/issue/fields/textarea.tmpl | 27 +++++- templates/repo/issue/new_form.tmpl | 7 +- .../js/features/comp/ComboMarkdownEditor.js | 82 ++++--------------- web_src/js/features/comp/ImagePaste.js | 8 ++ web_src/js/features/comp/QuickSubmit.js | 4 +- web_src/js/features/comp/TextExpander.js | 59 +++++++++++++ web_src/js/features/repo-issue.js | 56 +++++++++++++ web_src/js/features/repo-legacy.js | 22 ++--- 8 files changed, 176 insertions(+), 89 deletions(-) create mode 100644 web_src/js/features/comp/TextExpander.js diff --git a/templates/repo/issue/fields/textarea.tmpl b/templates/repo/issue/fields/textarea.tmpl index ad3c5efa04..49d51eb5a8 100644 --- a/templates/repo/issue/fields/textarea.tmpl +++ b/templates/repo/issue/fields/textarea.tmpl @@ -1,6 +1,25 @@ -
+{{$useMarkdownEditor := not .item.Attributes.render}} +
{{template "repo/issue/fields/header" .}} - {{/* FIXME: preview markdown result */}} - {{/* FIXME: required validation for markdown editor */}} - + + {{/* the real form element to provide the value */}} + + + {{if $useMarkdownEditor}} + {{template "shared/combomarkdowneditor" (dict + "locale" .root.locale + "ContainerClasses" "gt-hidden" + "MarkdownPreviewUrl" (print .root.RepoLink "/markup") + "MarkdownPreviewContext" .root.RepoLink + "TextareaContent" .item.Attributes.value + "TextareaPlaceholder" .item.Attributes.placeholder + "DropzoneParentContainer" ".combo-editor-dropzone" + )}} + + {{if .root.IsAttachmentEnabled}} +
+ {{template "repo/upload" .root}} +
+ {{end}} + {{end}}
diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index 196e911061..c12b8149b0 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -24,18 +24,13 @@ {{else if eq .Type "markdown"}} {{template "repo/issue/fields/markdown" dict "Context" $.Context "item" .}} {{else if eq .Type "textarea"}} - {{template "repo/issue/fields/textarea" dict "Context" $.Context "item" .}} + {{template "repo/issue/fields/textarea" dict "Context" $.Context "item" . "root" $}} {{else if eq .Type "dropdown"}} {{template "repo/issue/fields/dropdown" dict "Context" $.Context "item" .}} {{else if eq .Type "checkboxes"}} {{template "repo/issue/fields/checkboxes" dict "Context" $.Context "item" .}} {{end}} {{end}} - {{if .IsAttachmentEnabled}} -
- {{template "repo/upload" .}} -
- {{end}} {{else}} {{template "repo/issue/comment_tab" .}} {{end}} diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index 90d1bcde5a..103e71daae 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -5,10 +5,9 @@ import {attachTribute} from '../tribute.js'; import {hideElem, showElem, autosize} from '../../utils/dom.js'; import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js'; import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; -import {emojiString} from '../emoji.js'; import {renderPreviewPanelContent} from '../repo-editor.js'; -import {matchEmoji, matchMention} from '../../utils/match.js'; import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; +import {initTextExpander} from './TextExpander.js'; let elementIdCounter = 0; @@ -43,14 +42,12 @@ class ComboMarkdownEditor { async init() { this.prepareEasyMDEToolbarActions(); + this.setupContainer(); this.setupTab(); this.setupDropzone(); this.setupTextarea(); - this.setupExpander(); - if (this.userPreferredEditor === 'easymde') { - await this.switchToEasyMDE(); - } + await this.switchToUserPreference(); } applyEditorHeights(el, heights) { @@ -60,6 +57,11 @@ class ComboMarkdownEditor { if (heights.maxHeight) el.style.maxHeight = heights.maxHeight; } + setupContainer() { + initTextExpander(this.container.querySelector('text-expander')); + this.container.addEventListener('ce-editor-content-changed', (e) => this.options?.onContentChanged?.(this, e)); + } + setupTextarea() { this.textarea = this.container.querySelector('.markdown-text-editor'); this.textarea._giteaComboMarkdownEditor = this; @@ -103,64 +105,6 @@ class ComboMarkdownEditor { } } - setupExpander() { - const expander = this.container.querySelector('text-expander'); - expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { - if (key === ':') { - const matches = matchEmoji(text); - if (!matches.length) return provide({matched: false}); - - const ul = document.createElement('ul'); - ul.classList.add('suggestions'); - for (const name of matches) { - const emoji = emojiString(name); - const li = document.createElement('li'); - li.setAttribute('role', 'option'); - li.setAttribute('data-value', emoji); - li.textContent = `${emoji} ${name}`; - ul.append(li); - } - - provide({matched: true, fragment: ul}); - } else if (key === '@') { - const matches = matchMention(text); - if (!matches.length) return provide({matched: false}); - - const ul = document.createElement('ul'); - ul.classList.add('suggestions'); - for (const {value, name, fullname, avatar} of matches) { - const li = document.createElement('li'); - li.setAttribute('role', 'option'); - li.setAttribute('data-value', `${key}${value}`); - - const img = document.createElement('img'); - img.src = avatar; - li.append(img); - - const nameSpan = document.createElement('span'); - nameSpan.textContent = name; - li.append(nameSpan); - - if (fullname && fullname.toLowerCase() !== name) { - const fullnameSpan = document.createElement('span'); - fullnameSpan.classList.add('fullname'); - fullnameSpan.textContent = fullname; - li.append(fullnameSpan); - } - - ul.append(li); - } - - provide({matched: true, fragment: ul}); - } - }); - expander?.addEventListener('text-expander-value', ({detail}) => { - if (detail?.item) { - detail.value = detail.item.getAttribute('data-value'); - } - }); - } - setupDropzone() { const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container'); if (dropzoneParentContainer) { @@ -224,7 +168,16 @@ class ComboMarkdownEditor { return processed; } + async switchToUserPreference() { + if (this.userPreferredEditor === 'easymde') { + await this.switchToEasyMDE(); + } else { + this.switchToTextarea(); + } + } + switchToTextarea() { + if (!this.easyMDE) return; showElem(this.textareaMarkdownToolbar); if (this.easyMDE) { this.easyMDE.toTextArea(); @@ -233,6 +186,7 @@ class ComboMarkdownEditor { } async switchToEasyMDE() { + if (this.easyMDE) return; // 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 = { diff --git a/web_src/js/features/comp/ImagePaste.js b/web_src/js/features/comp/ImagePaste.js index 9145b24062..dc335495a3 100644 --- a/web_src/js/features/comp/ImagePaste.js +++ b/web_src/js/features/comp/ImagePaste.js @@ -25,6 +25,10 @@ function clipboardPastedImages(e) { return files; } +function triggerEditorContentChanged(target) { + target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); +} + class TextareaEditor { constructor(editor) { this.editor = editor; @@ -38,6 +42,7 @@ class TextareaEditor { editor.selectionStart = startPos; editor.selectionEnd = startPos + value.length; editor.focus(); + triggerEditorContentChanged(editor); } replacePlaceholder(oldVal, newVal) { @@ -54,6 +59,7 @@ class TextareaEditor { } editor.selectionStart = editor.selectionEnd; editor.focus(); + triggerEditorContentChanged(editor); } } @@ -70,6 +76,7 @@ class CodeMirrorEditor { endPoint.ch = startPoint.ch + value.length; editor.setSelection(startPoint, endPoint); editor.focus(); + triggerEditorContentChanged(editor.getTextArea()); } replacePlaceholder(oldVal, newVal) { @@ -84,6 +91,7 @@ class CodeMirrorEditor { endPoint.ch += newVal.length; editor.setSelection(endPoint, endPoint); editor.focus(); + triggerEditorContentChanged(editor.getTextArea()); } } diff --git a/web_src/js/features/comp/QuickSubmit.js b/web_src/js/features/comp/QuickSubmit.js index 43424a949f..d598a59655 100644 --- a/web_src/js/features/comp/QuickSubmit.js +++ b/web_src/js/features/comp/QuickSubmit.js @@ -6,7 +6,9 @@ export function handleGlobalEnterQuickSubmit(target) { if ($form.length) { // here use the event to trigger the submit event (instead of calling `submit()` method directly) // otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog - $form.trigger('submit'); + if ($form[0].checkValidity()) { + $form.trigger('submit'); + } } else { // if no form, then the editor is for an AJAX request, dispatch an event to the target, let the target's event handler to do the AJAX request. // the 'ce-' prefix means this is a CustomEvent diff --git a/web_src/js/features/comp/TextExpander.js b/web_src/js/features/comp/TextExpander.js new file mode 100644 index 0000000000..e2840610df --- /dev/null +++ b/web_src/js/features/comp/TextExpander.js @@ -0,0 +1,59 @@ +import {matchEmoji, matchMention} from '../../utils/match.js'; +import {emojiString} from '../emoji.js'; + +export function initTextExpander(expander) { + expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { + if (key === ':') { + const matches = matchEmoji(text); + if (!matches.length) return provide({matched: false}); + + const ul = document.createElement('ul'); + ul.classList.add('suggestions'); + for (const name of matches) { + const emoji = emojiString(name); + const li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.setAttribute('data-value', emoji); + li.textContent = `${emoji} ${name}`; + ul.append(li); + } + + provide({matched: true, fragment: ul}); + } else if (key === '@') { + const matches = matchMention(text); + if (!matches.length) return provide({matched: false}); + + const ul = document.createElement('ul'); + ul.classList.add('suggestions'); + for (const {value, name, fullname, avatar} of matches) { + const li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.setAttribute('data-value', `${key}${value}`); + + const img = document.createElement('img'); + img.src = avatar; + li.append(img); + + const nameSpan = document.createElement('span'); + nameSpan.textContent = name; + li.append(nameSpan); + + if (fullname && fullname.toLowerCase() !== name) { + const fullnameSpan = document.createElement('span'); + fullnameSpan.classList.add('fullname'); + fullnameSpan.textContent = fullname; + li.append(fullnameSpan); + } + + ul.append(li); + } + + provide({matched: true, fragment: ul}); + } + }); + expander?.addEventListener('text-expander-value', ({detail}) => { + if (detail?.item) { + detail.value = detail.item.getAttribute('data-value'); + } + }); +} diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 7e1249ed2f..8ecc7aa4ca 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -665,3 +665,59 @@ export function initRepoIssueGotoID() { } }); } + +export function initSingleCommentEditor($commentForm) { + // pages: + // * normal new issue/pr page, no status-button + // * issue/pr view page, with comment form, has status-button + const opts = {}; + const $statusButton = $('#status-button'); + if ($statusButton.length) { + $statusButton.on('click', (e) => { + e.preventDefault(); + $('#status').val($statusButton.data('status-val')); + $('#comment-form').trigger('submit'); + }); + opts.onContentChanged = (editor) => { + $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status')); + }; + } + initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), opts); +} + +export function initIssueTemplateCommentEditors($commentForm) { + // pages: + // * new issue with issue template + const $comboFields = $commentForm.find('.combo-editor-dropzone'); + + const initCombo = async ($combo) => { + const $dropzoneContainer = $combo.find('.form-field-dropzone'); + const $formField = $combo.find('.form-field-real'); + const $markdownEditor = $combo.find('.combo-markdown-editor'); + + const editor = await initComboMarkdownEditor($markdownEditor, { + onContentChanged: (editor) => { + $formField.val(editor.value()); + } + }); + + $formField.on('focus', async () => { + // deactivate all markdown editors + showElem($commentForm.find('.combo-editor-dropzone .form-field-real')); + hideElem($commentForm.find('.combo-editor-dropzone .combo-markdown-editor')); + hideElem($commentForm.find('.combo-editor-dropzone .form-field-dropzone')); + + // activate this markdown editor + hideElem($formField); + showElem($markdownEditor); + showElem($dropzoneContainer); + + await editor.switchToUserPreference(); + editor.focus(); + }); + }; + + for (const el of $comboFields) { + initCombo($(el)); + } +} diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index c3bd0ccb76..2804844d81 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -3,7 +3,7 @@ import { initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete, initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue, initRepoIssueTitleEdit, initRepoIssueWipToggle, - initRepoPullRequestUpdate, updateIssuesMeta, handleReply + initRepoPullRequestUpdate, updateIssuesMeta, handleReply, initIssueTemplateCommentEditors, initSingleCommentEditor, } from './repo-issue.js'; import {initUnicodeEscapeButton} from './repo-unicode-escape.js'; import {svg} from '../svg.js'; @@ -53,6 +53,13 @@ export function initRepoCommentForm() { return; } + if ($commentForm.find('.field.combo-editor-dropzone').length) { + // at the moment, if a form has multiple combo-markdown-editors, it must be a issue template form + initIssueTemplateCommentEditors($commentForm); + } else { + initSingleCommentEditor($commentForm); + } + function initBranchSelector() { const $selectBranch = $('.ui.select-branch'); const $branchMenu = $selectBranch.find('.reference-list-menu'); @@ -82,19 +89,6 @@ export function initRepoCommentForm() { }); } - 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(); // List submits