mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Add some handy markdown editor features (#32400)
There were some missing features from EasyMDE: 1. H1 - H3 style 2. Auto add task list 3. Insert a table And added some tests
This commit is contained in:
		| @@ -15,8 +15,14 @@ import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts'; | ||||
| import {initTextExpander} from './TextExpander.ts'; | ||||
| import {showErrorToast} from '../../modules/toast.ts'; | ||||
| import {POST} from '../../modules/fetch.ts'; | ||||
| import {EventEditorContentChanged, initTextareaMarkdown, triggerEditorContentChanged} from './EditorMarkdown.ts'; | ||||
| import { | ||||
|   EventEditorContentChanged, | ||||
|   initTextareaMarkdown, | ||||
|   textareaInsertText, | ||||
|   triggerEditorContentChanged, | ||||
| } from './EditorMarkdown.ts'; | ||||
| import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts'; | ||||
| import {createTippy} from '../../modules/tippy.ts'; | ||||
|  | ||||
| let elementIdCounter = 0; | ||||
|  | ||||
| @@ -122,8 +128,7 @@ export class ComboMarkdownEditor { | ||||
|     const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text'); | ||||
|     monospaceButton.setAttribute('data-tooltip-content', monospaceText); | ||||
|     monospaceButton.setAttribute('aria-checked', String(monospaceEnabled)); | ||||
|  | ||||
|     monospaceButton?.addEventListener('click', (e) => { | ||||
|     monospaceButton.addEventListener('click', (e) => { | ||||
|       e.preventDefault(); | ||||
|       const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true'; | ||||
|       localStorage.setItem('markdown-editor-monospace', String(enabled)); | ||||
| @@ -134,12 +139,14 @@ export class ComboMarkdownEditor { | ||||
|     }); | ||||
|  | ||||
|     const easymdeButton = this.container.querySelector('.markdown-switch-easymde'); | ||||
|     easymdeButton?.addEventListener('click', async (e) => { | ||||
|     easymdeButton.addEventListener('click', async (e) => { | ||||
|       e.preventDefault(); | ||||
|       this.userPreferredEditor = 'easymde'; | ||||
|       await this.switchToEasyMDE(); | ||||
|     }); | ||||
|  | ||||
|     this.initMarkdownButtonTableAdd(); | ||||
|  | ||||
|     initTextareaMarkdown(this.textarea); | ||||
|     initTextareaEvents(this.textarea, this.dropzone); | ||||
|   } | ||||
| @@ -219,6 +226,42 @@ export class ComboMarkdownEditor { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   generateMarkdownTable(rows: number, cols: number): string { | ||||
|     const tableLines = []; | ||||
|     tableLines.push( | ||||
|       `| ${'Header '.repeat(cols).trim().split(' ').join(' | ')} |`, | ||||
|       `| ${'--- '.repeat(cols).trim().split(' ').join(' | ')} |`, | ||||
|     ); | ||||
|     for (let i = 0; i < rows; i++) { | ||||
|       tableLines.push(`| ${'Cell '.repeat(cols).trim().split(' ').join(' | ')} |`); | ||||
|     } | ||||
|     return tableLines.join('\n'); | ||||
|   } | ||||
|  | ||||
|   initMarkdownButtonTableAdd() { | ||||
|     const addTableButton = this.container.querySelector('.markdown-button-table-add'); | ||||
|     const addTablePanel = this.container.querySelector('.markdown-add-table-panel'); | ||||
|     // here the tippy can't attach to the button because the button already owns a tippy for tooltip | ||||
|     const addTablePanelTippy = createTippy(addTablePanel, { | ||||
|       content: addTablePanel, | ||||
|       trigger: 'manual', | ||||
|       placement: 'bottom', | ||||
|       hideOnClick: true, | ||||
|       interactive: true, | ||||
|       getReferenceClientRect: () => addTableButton.getBoundingClientRect(), | ||||
|     }); | ||||
|     addTableButton.addEventListener('click', () => addTablePanelTippy.show()); | ||||
|  | ||||
|     addTablePanel.querySelector('.ui.button.primary').addEventListener('click', () => { | ||||
|       let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]').value); | ||||
|       let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]').value); | ||||
|       rows = Math.max(1, Math.min(100, rows)); | ||||
|       cols = Math.max(1, Math.min(100, cols)); | ||||
|       textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`); | ||||
|       addTablePanelTippy.hide(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   switchTabToEditor() { | ||||
|     this.tabEditor.click(); | ||||
|   } | ||||
|   | ||||
							
								
								
									
										27
									
								
								web_src/js/features/comp/EditorMarkdown.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								web_src/js/features/comp/EditorMarkdown.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import {initTextareaMarkdown} from './EditorMarkdown.ts'; | ||||
|  | ||||
| test('EditorMarkdown', () => { | ||||
|   const textarea = document.createElement('textarea'); | ||||
|   initTextareaMarkdown(textarea); | ||||
|  | ||||
|   const testInput = (value, expected) => { | ||||
|     textarea.value = value; | ||||
|     textarea.setSelectionRange(value.length, value.length); | ||||
|     const e = new KeyboardEvent('keydown', {key: 'Enter', cancelable: true}); | ||||
|     textarea.dispatchEvent(e); | ||||
|     if (!e.defaultPrevented) textarea.value += '\n'; | ||||
|     expect(textarea.value).toEqual(expected); | ||||
|   }; | ||||
|  | ||||
|   testInput('-', '-\n'); | ||||
|   testInput('1.', '1.\n'); | ||||
|  | ||||
|   testInput('- ', ''); | ||||
|   testInput('1. ', ''); | ||||
|  | ||||
|   testInput('- x', '- x\n- '); | ||||
|   testInput('- [ ]', '- [ ]\n- '); | ||||
|   testInput('- [ ] foo', '- [ ] foo\n- [ ] '); | ||||
|   testInput('* [x] foo', '* [x] foo\n* [ ] '); | ||||
|   testInput('1. [x] foo', '1. [x] foo\n1. [ ] '); | ||||
| }); | ||||
| @@ -4,6 +4,16 @@ export function triggerEditorContentChanged(target) { | ||||
|   target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true})); | ||||
| } | ||||
|  | ||||
| export function textareaInsertText(textarea, value) { | ||||
|   const startPos = textarea.selectionStart; | ||||
|   const endPos = textarea.selectionEnd; | ||||
|   textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos); | ||||
|   textarea.selectionStart = startPos; | ||||
|   textarea.selectionEnd = startPos + value.length; | ||||
|   textarea.focus(); | ||||
|   triggerEditorContentChanged(textarea); | ||||
| } | ||||
|  | ||||
| function handleIndentSelection(textarea, e) { | ||||
|   const selStart = textarea.selectionStart; | ||||
|   const selEnd = textarea.selectionEnd; | ||||
| @@ -46,7 +56,7 @@ function handleIndentSelection(textarea, e) { | ||||
|   triggerEditorContentChanged(textarea); | ||||
| } | ||||
|  | ||||
| function handleNewline(textarea, e) { | ||||
| function handleNewline(textarea: HTMLTextAreaElement, e: Event) { | ||||
|   const selStart = textarea.selectionStart; | ||||
|   const selEnd = textarea.selectionEnd; | ||||
|   if (selEnd !== selStart) return; // do not process when there is a selection | ||||
| @@ -66,9 +76,9 @@ function handleNewline(textarea, e) { | ||||
|   const indention = /^\s*/.exec(line)[0]; | ||||
|   line = line.slice(indention.length); | ||||
|  | ||||
|   // parse the prefixes: "1. ", "- ", "* ", "[ ] ", "[x] " | ||||
|   // parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists | ||||
|   // there must be a space after the prefix because none of "1.foo" / "-foo" is a list item | ||||
|   const prefixMatch = /^([0-9]+\.|[-*]|\[ \]|\[x\])\s/.exec(line); | ||||
|   const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(line); | ||||
|   let prefix = ''; | ||||
|   if (prefixMatch) { | ||||
|     prefix = prefixMatch[0]; | ||||
| @@ -85,8 +95,9 @@ function handleNewline(textarea, e) { | ||||
|   } else { | ||||
|     // start a new line with the same indention and prefix | ||||
|     let newPrefix = prefix; | ||||
|     if (newPrefix === '[x]') newPrefix = '[ ]'; | ||||
|     if (/^\d+\./.test(newPrefix)) newPrefix = `1. `; // a simple approach, otherwise it needs to parse the lines after the current line | ||||
|     // a simple approach, otherwise it needs to parse the lines after the current line | ||||
|     if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`; | ||||
|     newPrefix = newPrefix.replace('[x]', '[ ]'); | ||||
|     const newLine = `\n${indention}${newPrefix}`; | ||||
|     textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd); | ||||
|     textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import {imageInfo} from '../../utils/image.ts'; | ||||
| import {replaceTextareaSelection} from '../../utils/dom.ts'; | ||||
| import {isUrl} from '../../utils/url.ts'; | ||||
| import {triggerEditorContentChanged} from './EditorMarkdown.ts'; | ||||
| import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts'; | ||||
| import { | ||||
|   DropzoneCustomEventRemovedFile, | ||||
|   DropzoneCustomEventUploadDone, | ||||
| @@ -41,14 +41,7 @@ class TextareaEditor { | ||||
|   } | ||||
|  | ||||
|   insertPlaceholder(value) { | ||||
|     const editor = this.editor; | ||||
|     const startPos = editor.selectionStart; | ||||
|     const endPos = editor.selectionEnd; | ||||
|     editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos); | ||||
|     editor.selectionStart = startPos; | ||||
|     editor.selectionEnd = startPos + value.length; | ||||
|     editor.focus(); | ||||
|     triggerEditorContentChanged(editor); | ||||
|     textareaInsertText(this.editor, value); | ||||
|   } | ||||
|  | ||||
|   replacePlaceholder(oldVal, newVal) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user