You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ComboMarkdownEditor.js 12KB

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