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