gitea源码

ComboMarkdownEditor.ts 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. import '@github/markdown-toolbar-element';
  2. import '@github/text-expander-element';
  3. import {attachTribute} from '../tribute.ts';
  4. import {hideElem, showElem, autosize, isElemVisible, generateElemId} from '../../utils/dom.ts';
  5. import {
  6. EventUploadStateChanged,
  7. initEasyMDEPaste,
  8. initTextareaEvents,
  9. triggerUploadStateChanged,
  10. } from './EditorUpload.ts';
  11. import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts';
  12. import {renderPreviewPanelContent} from '../repo-editor.ts';
  13. import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
  14. import {initTextExpander} from './TextExpander.ts';
  15. import {showErrorToast} from '../../modules/toast.ts';
  16. import {POST} from '../../modules/fetch.ts';
  17. import {
  18. EventEditorContentChanged,
  19. initTextareaMarkdown,
  20. textareaInsertText,
  21. triggerEditorContentChanged,
  22. } from './EditorMarkdown.ts';
  23. import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
  24. import {createTippy} from '../../modules/tippy.ts';
  25. import {fomanticQuery} from '../../modules/fomantic/base.ts';
  26. import type EasyMDE from 'easymde';
  27. /**
  28. * validate if the given textarea is non-empty.
  29. * @param {HTMLTextAreaElement} textarea - The textarea element to be validated.
  30. * @returns {boolean} returns true if validation succeeded.
  31. */
  32. export function validateTextareaNonEmpty(textarea: HTMLTextAreaElement) {
  33. // When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
  34. // The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
  35. if (!textarea.value) {
  36. if (isElemVisible(textarea)) {
  37. textarea.required = true;
  38. const form = textarea.closest('form');
  39. form?.reportValidity();
  40. } else {
  41. // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
  42. showErrorToast('Require non-empty content');
  43. }
  44. return false;
  45. }
  46. return true;
  47. }
  48. type Heights = {
  49. minHeight?: string,
  50. height?: string,
  51. maxHeight?: string,
  52. };
  53. type ComboMarkdownEditorOptions = {
  54. editorHeights?: Heights,
  55. easyMDEOptions?: EasyMDE.Options,
  56. };
  57. type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
  58. type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: any};
  59. export class ComboMarkdownEditor {
  60. static EventEditorContentChanged = EventEditorContentChanged;
  61. static EventUploadStateChanged = EventUploadStateChanged;
  62. public container: HTMLElement;
  63. options: ComboMarkdownEditorOptions;
  64. tabEditor: HTMLElement;
  65. tabPreviewer: HTMLElement;
  66. supportEasyMDE: boolean;
  67. easyMDE: any;
  68. easyMDEToolbarActions: any;
  69. easyMDEToolbarDefault: any;
  70. textarea: ComboMarkdownEditorTextarea;
  71. textareaMarkdownToolbar: HTMLElement;
  72. textareaAutosize: any;
  73. dropzone: HTMLElement;
  74. attachedDropzoneInst: any;
  75. previewMode: string;
  76. previewUrl: string;
  77. previewContext: string;
  78. constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
  79. if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
  80. container._giteaComboMarkdownEditor = this;
  81. this.options = options;
  82. this.container = container;
  83. }
  84. async init() {
  85. this.prepareEasyMDEToolbarActions();
  86. this.setupContainer();
  87. this.setupTab();
  88. await this.setupDropzone(); // textarea depends on dropzone
  89. this.setupTextarea();
  90. await this.switchToUserPreference();
  91. }
  92. applyEditorHeights(el: HTMLElement, heights: Heights) {
  93. if (!heights) return;
  94. if (heights.minHeight) el.style.minHeight = heights.minHeight;
  95. if (heights.height) el.style.height = heights.height;
  96. if (heights.maxHeight) el.style.maxHeight = heights.maxHeight;
  97. }
  98. setupContainer() {
  99. this.supportEasyMDE = this.container.getAttribute('data-support-easy-mde') === 'true';
  100. this.previewMode = this.container.getAttribute('data-content-mode');
  101. this.previewUrl = this.container.getAttribute('data-preview-url');
  102. this.previewContext = this.container.getAttribute('data-preview-context');
  103. initTextExpander(this.container.querySelector('text-expander'));
  104. }
  105. setupTextarea() {
  106. this.textarea = this.container.querySelector('.markdown-text-editor');
  107. this.textarea._giteaComboMarkdownEditor = this;
  108. this.textarea.id = generateElemId(`_combo_markdown_editor_`);
  109. this.textarea.addEventListener('input', () => triggerEditorContentChanged(this.container));
  110. this.applyEditorHeights(this.textarea, this.options.editorHeights);
  111. if (this.textarea.getAttribute('data-disable-autosize') !== 'true') {
  112. this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130});
  113. }
  114. this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar');
  115. this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id);
  116. for (const el of this.textareaMarkdownToolbar.querySelectorAll('.markdown-toolbar-button')) {
  117. // upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70
  118. el.setAttribute('role', 'button');
  119. // the editor usually is in a form, so the buttons should have "type=button", avoiding conflicting with the form's submit.
  120. if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
  121. }
  122. const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
  123. const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
  124. const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
  125. monospaceButton.setAttribute('data-tooltip-content', monospaceText);
  126. monospaceButton.setAttribute('aria-checked', String(monospaceEnabled));
  127. monospaceButton.addEventListener('click', (e) => {
  128. e.preventDefault();
  129. const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true';
  130. localStorage.setItem('markdown-editor-monospace', String(enabled));
  131. this.textarea.classList.toggle('tw-font-mono', enabled);
  132. const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text');
  133. monospaceButton.setAttribute('data-tooltip-content', text);
  134. monospaceButton.setAttribute('aria-checked', String(enabled));
  135. });
  136. if (this.supportEasyMDE) {
  137. const easymdeButton = this.container.querySelector('.markdown-switch-easymde');
  138. easymdeButton.addEventListener('click', async (e) => {
  139. e.preventDefault();
  140. this.userPreferredEditor = 'easymde';
  141. await this.switchToEasyMDE();
  142. });
  143. }
  144. this.initMarkdownButtonTableAdd();
  145. initTextareaMarkdown(this.textarea);
  146. initTextareaEvents(this.textarea, this.dropzone);
  147. }
  148. async setupDropzone() {
  149. const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
  150. if (!dropzoneParentContainer) return;
  151. this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone');
  152. if (!this.dropzone) return;
  153. this.attachedDropzoneInst = await initDropzone(this.dropzone);
  154. // dropzone events
  155. // * "processing" means a file is being uploaded
  156. // * "queuecomplete" means all files have been uploaded
  157. this.attachedDropzoneInst.on('processing', () => triggerUploadStateChanged(this.container));
  158. this.attachedDropzoneInst.on('queuecomplete', () => triggerUploadStateChanged(this.container));
  159. }
  160. dropzoneGetFiles() {
  161. if (!this.dropzone) return null;
  162. return Array.from(this.dropzone.querySelectorAll<HTMLInputElement>('.files [name=files]'), (el) => el.value);
  163. }
  164. dropzoneReloadFiles() {
  165. if (!this.dropzone) return;
  166. this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
  167. }
  168. dropzoneSubmitReload() {
  169. if (!this.dropzone) return;
  170. this.attachedDropzoneInst.emit('submit');
  171. this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
  172. }
  173. isUploading() {
  174. if (!this.dropzone) return false;
  175. return this.attachedDropzoneInst.getQueuedFiles().length || this.attachedDropzoneInst.getUploadingFiles().length;
  176. }
  177. setupTab() {
  178. const tabs = this.container.querySelectorAll<HTMLElement>('.tabular.menu > .item');
  179. if (!tabs.length) return;
  180. // Fomantic Tab requires the "data-tab" to be globally unique.
  181. // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
  182. const tabIdSuffix = generateElemId();
  183. this.tabEditor = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer');
  184. this.tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer');
  185. this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
  186. this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
  187. const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]');
  188. const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]');
  189. panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
  190. panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
  191. this.tabEditor.addEventListener('click', () => {
  192. requestAnimationFrame(() => {
  193. this.focus();
  194. });
  195. });
  196. fomanticQuery(tabs).tab();
  197. this.tabPreviewer.addEventListener('click', async () => {
  198. const formData = new FormData();
  199. formData.append('mode', this.previewMode);
  200. formData.append('context', this.previewContext);
  201. formData.append('text', this.value());
  202. const response = await POST(this.previewUrl, {data: formData});
  203. const data = await response.text();
  204. renderPreviewPanelContent(panelPreviewer, data);
  205. });
  206. }
  207. generateMarkdownTable(rows: number, cols: number): string {
  208. const tableLines = [];
  209. tableLines.push(
  210. `| ${'Header '.repeat(cols).trim().split(' ').join(' | ')} |`,
  211. `| ${'--- '.repeat(cols).trim().split(' ').join(' | ')} |`,
  212. );
  213. for (let i = 0; i < rows; i++) {
  214. tableLines.push(`| ${'Cell '.repeat(cols).trim().split(' ').join(' | ')} |`);
  215. }
  216. return tableLines.join('\n');
  217. }
  218. initMarkdownButtonTableAdd() {
  219. const addTableButton = this.container.querySelector('.markdown-button-table-add');
  220. const addTablePanel = this.container.querySelector('.markdown-add-table-panel');
  221. // here the tippy can't attach to the button because the button already owns a tippy for tooltip
  222. const addTablePanelTippy = createTippy(addTablePanel, {
  223. content: addTablePanel,
  224. trigger: 'manual',
  225. placement: 'bottom',
  226. hideOnClick: true,
  227. interactive: true,
  228. getReferenceClientRect: () => addTableButton.getBoundingClientRect(),
  229. });
  230. addTableButton.addEventListener('click', () => addTablePanelTippy.show());
  231. addTablePanel.querySelector('.ui.button.primary').addEventListener('click', () => {
  232. let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]').value);
  233. let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]').value);
  234. rows = Math.max(1, Math.min(100, rows));
  235. cols = Math.max(1, Math.min(100, cols));
  236. textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
  237. addTablePanelTippy.hide();
  238. });
  239. }
  240. switchTabToEditor() {
  241. this.tabEditor.click();
  242. }
  243. prepareEasyMDEToolbarActions() {
  244. this.easyMDEToolbarDefault = [
  245. 'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3',
  246. 'heading-bigger', 'heading-smaller', '|', 'code', 'quote', '|', 'gitea-checkbox-empty',
  247. 'gitea-checkbox-checked', '|', 'unordered-list', 'ordered-list', '|', 'link', 'image',
  248. 'table', 'horizontal-rule', '|', 'gitea-switch-to-textarea',
  249. ];
  250. }
  251. parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions: any) {
  252. this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
  253. const processed = [];
  254. for (const action of actions) {
  255. const actionButton = this.easyMDEToolbarActions[action];
  256. if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
  257. processed.push(actionButton);
  258. }
  259. return processed;
  260. }
  261. async switchToUserPreference() {
  262. if (this.userPreferredEditor === 'easymde' && this.supportEasyMDE) {
  263. await this.switchToEasyMDE();
  264. } else {
  265. this.switchToTextarea();
  266. }
  267. }
  268. switchToTextarea() {
  269. if (!this.easyMDE) return;
  270. showElem(this.textareaMarkdownToolbar);
  271. if (this.easyMDE) {
  272. this.easyMDE.toTextArea();
  273. this.easyMDE = null;
  274. }
  275. }
  276. async switchToEasyMDE() {
  277. if (this.easyMDE) return;
  278. // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
  279. const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
  280. const easyMDEOpt: EasyMDE.Options = {
  281. autoDownloadFontAwesome: false,
  282. element: this.textarea,
  283. forceSync: true,
  284. renderingConfig: {singleLineBreaks: false},
  285. indentWithTabs: false,
  286. tabSize: 4,
  287. spellChecker: false,
  288. inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
  289. nativeSpellcheck: true,
  290. ...this.options.easyMDEOptions,
  291. };
  292. easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault);
  293. this.easyMDE = new EasyMDE(easyMDEOpt);
  294. this.easyMDE.codemirror.on('change', () => triggerEditorContentChanged(this.container));
  295. this.easyMDE.codemirror.setOption('extraKeys', {
  296. 'Cmd-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
  297. 'Ctrl-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
  298. Enter: (cm: any) => {
  299. const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
  300. if (!tributeContainer || tributeContainer.style.display === 'none') {
  301. cm.execCommand('newlineAndIndent');
  302. }
  303. },
  304. Up: (cm: any) => {
  305. const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
  306. if (!tributeContainer || tributeContainer.style.display === 'none') {
  307. return cm.execCommand('goLineUp');
  308. }
  309. },
  310. Down: (cm: any) => {
  311. const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
  312. if (!tributeContainer || tributeContainer.style.display === 'none') {
  313. return cm.execCommand('goLineDown');
  314. }
  315. },
  316. });
  317. this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
  318. await attachTribute(this.easyMDE.codemirror.getInputField());
  319. if (this.dropzone) {
  320. initEasyMDEPaste(this.easyMDE, this.dropzone);
  321. }
  322. hideElem(this.textareaMarkdownToolbar);
  323. }
  324. value(v: any = undefined) {
  325. if (v === undefined) {
  326. if (this.easyMDE) {
  327. return this.easyMDE.value();
  328. }
  329. return this.textarea.value;
  330. }
  331. if (this.easyMDE) {
  332. this.easyMDE.value(v);
  333. } else {
  334. this.textarea.value = v;
  335. }
  336. this.textareaAutosize?.resizeToFit();
  337. }
  338. focus() {
  339. if (this.easyMDE) {
  340. this.easyMDE.codemirror.focus();
  341. } else {
  342. this.textarea.focus();
  343. }
  344. }
  345. moveCursorToEnd() {
  346. this.textarea.focus();
  347. this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length);
  348. if (this.easyMDE) {
  349. this.easyMDE.codemirror.focus();
  350. this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0);
  351. }
  352. }
  353. get userPreferredEditor() {
  354. return window.localStorage.getItem(`markdown-editor-${this.previewMode ?? 'default'}`);
  355. }
  356. set userPreferredEditor(s) {
  357. window.localStorage.setItem(`markdown-editor-${this.previewMode ?? 'default'}`, s);
  358. }
  359. }
  360. export function getComboMarkdownEditor(el: any) {
  361. if (!el) return null;
  362. if (el.length) el = el[0];
  363. return el._giteaComboMarkdownEditor;
  364. }
  365. export async function initComboMarkdownEditor(container: HTMLElement, options:ComboMarkdownEditorOptions = {}) {
  366. if (!container) {
  367. throw new Error('initComboMarkdownEditor: container is null');
  368. }
  369. const editor = new ComboMarkdownEditor(container, options);
  370. await editor.init();
  371. return editor;
  372. }