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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  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('tw-font-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 tabs = $container[0].querySelectorAll('.tabular.menu > .item');
  119. // Fomantic Tab requires the "data-tab" to be globally unique.
  120. // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
  121. const tabEditor = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer');
  122. const tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer');
  123. tabEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`);
  124. tabPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`);
  125. const panelEditor = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-writer"]');
  126. const panelPreviewer = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-previewer"]');
  127. panelEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`);
  128. panelPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`);
  129. elementIdCounter++;
  130. tabEditor.addEventListener('click', () => {
  131. requestAnimationFrame(() => {
  132. this.focus();
  133. });
  134. });
  135. $(tabs).tab();
  136. this.previewUrl = tabPreviewer.getAttribute('data-preview-url');
  137. this.previewContext = tabPreviewer.getAttribute('data-preview-context');
  138. this.previewMode = this.options.previewMode ?? 'comment';
  139. this.previewWiki = this.options.previewWiki ?? false;
  140. tabPreviewer.addEventListener('click', async () => {
  141. const formData = new FormData();
  142. formData.append('mode', this.previewMode);
  143. formData.append('context', this.previewContext);
  144. formData.append('text', this.value());
  145. formData.append('wiki', this.previewWiki);
  146. const response = await POST(this.previewUrl, {data: formData});
  147. const data = await response.text();
  148. renderPreviewPanelContent($(panelPreviewer), data);
  149. });
  150. }
  151. prepareEasyMDEToolbarActions() {
  152. this.easyMDEToolbarDefault = [
  153. 'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3',
  154. 'heading-bigger', 'heading-smaller', '|', 'code', 'quote', '|', 'gitea-checkbox-empty',
  155. 'gitea-checkbox-checked', '|', 'unordered-list', 'ordered-list', '|', 'link', 'image',
  156. 'table', 'horizontal-rule', '|', 'gitea-switch-to-textarea',
  157. ];
  158. }
  159. parseEasyMDEToolbar(EasyMDE, actions) {
  160. this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(EasyMDE, this);
  161. const processed = [];
  162. for (const action of actions) {
  163. const actionButton = this.easyMDEToolbarActions[action];
  164. if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
  165. processed.push(actionButton);
  166. }
  167. return processed;
  168. }
  169. async switchToUserPreference() {
  170. if (this.userPreferredEditor === 'easymde') {
  171. await this.switchToEasyMDE();
  172. } else {
  173. this.switchToTextarea();
  174. }
  175. }
  176. switchToTextarea() {
  177. if (!this.easyMDE) return;
  178. showElem(this.textareaMarkdownToolbar);
  179. if (this.easyMDE) {
  180. this.easyMDE.toTextArea();
  181. this.easyMDE = null;
  182. }
  183. }
  184. async switchToEasyMDE() {
  185. if (this.easyMDE) return;
  186. // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
  187. const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
  188. const easyMDEOpt = {
  189. autoDownloadFontAwesome: false,
  190. element: this.textarea,
  191. forceSync: true,
  192. renderingConfig: {singleLineBreaks: false},
  193. indentWithTabs: false,
  194. tabSize: 4,
  195. spellChecker: false,
  196. inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
  197. nativeSpellcheck: true,
  198. ...this.options.easyMDEOptions,
  199. };
  200. easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault);
  201. this.easyMDE = new EasyMDE(easyMDEOpt);
  202. this.easyMDE.codemirror.on('change', (...args) => {this.options?.onContentChanged?.(this, ...args)});
  203. this.easyMDE.codemirror.setOption('extraKeys', {
  204. 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
  205. 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
  206. Enter: (cm) => {
  207. const tributeContainer = document.querySelector('.tribute-container');
  208. if (!tributeContainer || tributeContainer.style.display === 'none') {
  209. cm.execCommand('newlineAndIndent');
  210. }
  211. },
  212. Up: (cm) => {
  213. const tributeContainer = document.querySelector('.tribute-container');
  214. if (!tributeContainer || tributeContainer.style.display === 'none') {
  215. return cm.execCommand('goLineUp');
  216. }
  217. },
  218. Down: (cm) => {
  219. const tributeContainer = document.querySelector('.tribute-container');
  220. if (!tributeContainer || tributeContainer.style.display === 'none') {
  221. return cm.execCommand('goLineDown');
  222. }
  223. },
  224. });
  225. this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
  226. await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
  227. initEasyMDEPaste(this.easyMDE, this.dropzone);
  228. hideElem(this.textareaMarkdownToolbar);
  229. }
  230. value(v = undefined) {
  231. if (v === undefined) {
  232. if (this.easyMDE) {
  233. return this.easyMDE.value();
  234. }
  235. return this.textarea.value;
  236. }
  237. if (this.easyMDE) {
  238. this.easyMDE.value(v);
  239. } else {
  240. this.textarea.value = v;
  241. }
  242. this.textareaAutosize?.resizeToFit();
  243. }
  244. focus() {
  245. if (this.easyMDE) {
  246. this.easyMDE.codemirror.focus();
  247. } else {
  248. this.textarea.focus();
  249. }
  250. }
  251. moveCursorToEnd() {
  252. this.textarea.focus();
  253. this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length);
  254. if (this.easyMDE) {
  255. this.easyMDE.codemirror.focus();
  256. this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0);
  257. }
  258. }
  259. get userPreferredEditor() {
  260. return window.localStorage.getItem(`markdown-editor-${this.options.useScene ?? 'default'}`);
  261. }
  262. set userPreferredEditor(s) {
  263. window.localStorage.setItem(`markdown-editor-${this.options.useScene ?? 'default'}`, s);
  264. }
  265. }
  266. export function getComboMarkdownEditor(el) {
  267. if (el instanceof $) el = el[0];
  268. return el?._giteaComboMarkdownEditor;
  269. }
  270. export async function initComboMarkdownEditor(container, options = {}) {
  271. if (container instanceof $) {
  272. if (container.length !== 1) {
  273. throw new Error('initComboMarkdownEditor: container must be a single element');
  274. }
  275. container = container[0];
  276. }
  277. if (!container) {
  278. throw new Error('initComboMarkdownEditor: container is null');
  279. }
  280. const editor = new ComboMarkdownEditor(container, options);
  281. await editor.init();
  282. return editor;
  283. }