code.gitea.io/gitea@v1.21.7/web_src/js/features/comp/ComboMarkdownEditor.js (about) 1 import '@github/markdown-toolbar-element'; 2 import '@github/text-expander-element'; 3 import $ from 'jquery'; 4 import {attachTribute} from '../tribute.js'; 5 import {hideElem, showElem, autosize} from '../../utils/dom.js'; 6 import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js'; 7 import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; 8 import {renderPreviewPanelContent} from '../repo-editor.js'; 9 import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; 10 import {initTextExpander} from './TextExpander.js'; 11 import {showErrorToast} from '../../modules/toast.js'; 12 13 let elementIdCounter = 0; 14 15 /** 16 * validate if the given textarea is non-empty. 17 * @param {jQuery} $textarea 18 * @returns {boolean} returns true if validation succeeded. 19 */ 20 export function validateTextareaNonEmpty($textarea) { 21 // When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation. 22 // The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert. 23 if (!$textarea.val()) { 24 if ($textarea.is(':visible')) { 25 $textarea.prop('required', true); 26 const $form = $textarea.parents('form'); 27 $form[0]?.reportValidity(); 28 } else { 29 // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places. 30 showErrorToast('Require non-empty content'); 31 } 32 return false; 33 } 34 return true; 35 } 36 37 class ComboMarkdownEditor { 38 constructor(container, options = {}) { 39 container._giteaComboMarkdownEditor = this; 40 this.options = options; 41 this.container = container; 42 } 43 44 async init() { 45 this.prepareEasyMDEToolbarActions(); 46 this.setupContainer(); 47 this.setupTab(); 48 this.setupDropzone(); 49 this.setupTextarea(); 50 51 await this.switchToUserPreference(); 52 } 53 54 applyEditorHeights(el, heights) { 55 if (!heights) return; 56 if (heights.minHeight) el.style.minHeight = heights.minHeight; 57 if (heights.height) el.style.height = heights.height; 58 if (heights.maxHeight) el.style.maxHeight = heights.maxHeight; 59 } 60 61 setupContainer() { 62 initTextExpander(this.container.querySelector('text-expander')); 63 this.container.addEventListener('ce-editor-content-changed', (e) => this.options?.onContentChanged?.(this, e)); 64 } 65 66 setupTextarea() { 67 this.textarea = this.container.querySelector('.markdown-text-editor'); 68 this.textarea._giteaComboMarkdownEditor = this; 69 this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter++)}`; 70 this.textarea.addEventListener('input', (e) => this.options?.onContentChanged?.(this, e)); 71 this.applyEditorHeights(this.textarea, this.options.editorHeights); 72 73 if (this.textarea.getAttribute('data-disable-autosize') !== 'true') { 74 this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130}); 75 } 76 77 this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar'); 78 this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id); 79 for (const el of this.textareaMarkdownToolbar.querySelectorAll('.markdown-toolbar-button')) { 80 // upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70 81 el.setAttribute('role', 'button'); 82 // the editor usually is in a form, so the buttons should have "type=button", avoiding conflicting with the form's submit. 83 if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button'); 84 } 85 86 const monospaceButton = this.container.querySelector('.markdown-switch-monospace'); 87 const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true'; 88 const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text'); 89 monospaceButton.setAttribute('data-tooltip-content', monospaceText); 90 monospaceButton.setAttribute('aria-checked', String(monospaceEnabled)); 91 92 monospaceButton?.addEventListener('click', (e) => { 93 e.preventDefault(); 94 const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true'; 95 localStorage.setItem('markdown-editor-monospace', String(enabled)); 96 this.textarea.classList.toggle('gt-mono', enabled); 97 const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text'); 98 monospaceButton.setAttribute('data-tooltip-content', text); 99 monospaceButton.setAttribute('aria-checked', String(enabled)); 100 }); 101 102 const easymdeButton = this.container.querySelector('.markdown-switch-easymde'); 103 easymdeButton?.addEventListener('click', async (e) => { 104 e.preventDefault(); 105 this.userPreferredEditor = 'easymde'; 106 await this.switchToEasyMDE(); 107 }); 108 109 if (this.dropzone) { 110 initTextareaImagePaste(this.textarea, this.dropzone); 111 } 112 } 113 114 setupDropzone() { 115 const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container'); 116 if (dropzoneParentContainer) { 117 this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone'); 118 } 119 } 120 121 setupTab() { 122 const $container = $(this.container); 123 const $tabMenu = $container.find('.tabular.menu'); 124 const $tabs = $tabMenu.find('> .item'); 125 126 // Fomantic Tab requires the "data-tab" to be globally unique. 127 // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic. 128 const $tabEditor = $tabs.filter(`.item[data-tab-for="markdown-writer"]`); 129 const $tabPreviewer = $tabs.filter(`.item[data-tab-for="markdown-previewer"]`); 130 $tabEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`); 131 $tabPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`); 132 const $panelEditor = $container.find('.ui.tab[data-tab-panel="markdown-writer"]'); 133 const $panelPreviewer = $container.find('.ui.tab[data-tab-panel="markdown-previewer"]'); 134 $panelEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`); 135 $panelPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`); 136 elementIdCounter++; 137 138 $tabEditor[0].addEventListener('click', () => { 139 requestAnimationFrame(() => { 140 this.focus(); 141 }); 142 }); 143 144 $tabs.tab(); 145 146 this.previewUrl = $tabPreviewer.attr('data-preview-url'); 147 this.previewContext = $tabPreviewer.attr('data-preview-context'); 148 this.previewMode = this.options.previewMode ?? 'comment'; 149 this.previewWiki = this.options.previewWiki ?? false; 150 $tabPreviewer.on('click', () => { 151 $.post(this.previewUrl, { 152 _csrf: window.config.csrfToken, 153 mode: this.previewMode, 154 context: this.previewContext, 155 text: this.value(), 156 wiki: this.previewWiki, 157 }, (data) => { 158 renderPreviewPanelContent($panelPreviewer, data); 159 }); 160 }); 161 } 162 163 prepareEasyMDEToolbarActions() { 164 this.easyMDEToolbarDefault = [ 165 'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3', 166 'heading-bigger', 'heading-smaller', '|', 'code', 'quote', '|', 'gitea-checkbox-empty', 167 'gitea-checkbox-checked', '|', 'unordered-list', 'ordered-list', '|', 'link', 'image', 168 'table', 'horizontal-rule', '|', 'gitea-switch-to-textarea', 169 ]; 170 } 171 172 parseEasyMDEToolbar(EasyMDE, actions) { 173 this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(EasyMDE, this); 174 const processed = []; 175 for (const action of actions) { 176 const actionButton = this.easyMDEToolbarActions[action]; 177 if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`); 178 processed.push(actionButton); 179 } 180 return processed; 181 } 182 183 async switchToUserPreference() { 184 if (this.userPreferredEditor === 'easymde') { 185 await this.switchToEasyMDE(); 186 } else { 187 this.switchToTextarea(); 188 } 189 } 190 191 switchToTextarea() { 192 if (!this.easyMDE) return; 193 showElem(this.textareaMarkdownToolbar); 194 if (this.easyMDE) { 195 this.easyMDE.toTextArea(); 196 this.easyMDE = null; 197 } 198 } 199 200 async switchToEasyMDE() { 201 if (this.easyMDE) return; 202 // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles. 203 const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde'); 204 const easyMDEOpt = { 205 autoDownloadFontAwesome: false, 206 element: this.textarea, 207 forceSync: true, 208 renderingConfig: {singleLineBreaks: false}, 209 indentWithTabs: false, 210 tabSize: 4, 211 spellChecker: false, 212 inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable 213 nativeSpellcheck: true, 214 ...this.options.easyMDEOptions, 215 }; 216 easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault); 217 218 this.easyMDE = new EasyMDE(easyMDEOpt); 219 this.easyMDE.codemirror.on('change', (...args) => {this.options?.onContentChanged?.(this, ...args)}); 220 this.easyMDE.codemirror.setOption('extraKeys', { 221 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), 222 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), 223 Enter: (cm) => { 224 const tributeContainer = document.querySelector('.tribute-container'); 225 if (!tributeContainer || tributeContainer.style.display === 'none') { 226 cm.execCommand('newlineAndIndent'); 227 } 228 }, 229 Up: (cm) => { 230 const tributeContainer = document.querySelector('.tribute-container'); 231 if (!tributeContainer || tributeContainer.style.display === 'none') { 232 return cm.execCommand('goLineUp'); 233 } 234 }, 235 Down: (cm) => { 236 const tributeContainer = document.querySelector('.tribute-container'); 237 if (!tributeContainer || tributeContainer.style.display === 'none') { 238 return cm.execCommand('goLineDown'); 239 } 240 }, 241 }); 242 this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights); 243 await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true}); 244 initEasyMDEImagePaste(this.easyMDE, this.dropzone); 245 hideElem(this.textareaMarkdownToolbar); 246 } 247 248 value(v = undefined) { 249 if (v === undefined) { 250 if (this.easyMDE) { 251 return this.easyMDE.value(); 252 } 253 return this.textarea.value; 254 } 255 256 if (this.easyMDE) { 257 this.easyMDE.value(v); 258 } else { 259 this.textarea.value = v; 260 } 261 this.textareaAutosize?.resizeToFit(); 262 } 263 264 focus() { 265 if (this.easyMDE) { 266 this.easyMDE.codemirror.focus(); 267 } else { 268 this.textarea.focus(); 269 } 270 } 271 272 moveCursorToEnd() { 273 this.textarea.focus(); 274 this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length); 275 if (this.easyMDE) { 276 this.easyMDE.codemirror.focus(); 277 this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0); 278 } 279 } 280 281 get userPreferredEditor() { 282 return window.localStorage.getItem(`markdown-editor-${this.options.useScene ?? 'default'}`); 283 } 284 set userPreferredEditor(s) { 285 window.localStorage.setItem(`markdown-editor-${this.options.useScene ?? 'default'}`, s); 286 } 287 } 288 289 export function getComboMarkdownEditor(el) { 290 if (el instanceof $) el = el[0]; 291 return el?._giteaComboMarkdownEditor; 292 } 293 294 export async function initComboMarkdownEditor(container, options = {}) { 295 if (container instanceof $) { 296 if (container.length !== 1) { 297 throw new Error('initComboMarkdownEditor: container must be a single element'); 298 } 299 container = container[0]; 300 } 301 if (!container) { 302 throw new Error('initComboMarkdownEditor: container is null'); 303 } 304 const editor = new ComboMarkdownEditor(container, options); 305 await editor.init(); 306 return editor; 307 }