mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	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. <details><summary>screenshots</summary>   Display only one markdown editor:  Support file upload and ^V image pasting  </details> --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
		| @@ -1,6 +1,25 @@ | ||||
| <div class="field"> | ||||
| {{$useMarkdownEditor := not .item.Attributes.render}} | ||||
| <div class="field {{if $useMarkdownEditor}}combo-editor-dropzone{{end}}"> | ||||
| 	{{template "repo/issue/fields/header" .}} | ||||
| 	{{/* FIXME: preview markdown result */}} | ||||
| 	{{/* FIXME: required validation for markdown editor */}} | ||||
| 	<textarea name="form-field-{{.item.ID}}" placeholder="{{.item.Attributes.placeholder}}" {{if and .item.Validations.required .item.Attributes.render}}required{{end}}>{{.item.Attributes.value}}</textarea> | ||||
|  | ||||
| 	{{/* the real form element to provide the value */}} | ||||
| 	<textarea class="form-field-real" name="form-field-{{.item.ID}}" placeholder="{{.item.Attributes.placeholder}}" {{if and .item.Validations.required}}required{{end}}>{{.item.Attributes.value}}</textarea> | ||||
|  | ||||
| 	{{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}} | ||||
| 		<div class="gt-mt-4 form-field-dropzone gt-hidden"> | ||||
| 			{{template "repo/upload" .root}} | ||||
| 		</div> | ||||
| 		{{end}} | ||||
| 	{{end}} | ||||
| </div> | ||||
|   | ||||
| @@ -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}} | ||||
| 						<div class="field"> | ||||
| 							{{template "repo/upload" .}} | ||||
| 						</div> | ||||
| 						{{end}} | ||||
| 					{{else}} | ||||
| 						{{template "repo/issue/comment_tab" .}} | ||||
| 					{{end}} | ||||
|   | ||||
| @@ -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 = { | ||||
|   | ||||
| @@ -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()); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|     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 | ||||
|   | ||||
							
								
								
									
										59
									
								
								web_src/js/features/comp/TextExpander.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								web_src/js/features/comp/TextExpander.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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'); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
| @@ -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)); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 yp05327
					yp05327