mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Improve markdown textarea for indentation and lists (#31406)
Almost works like GitHub * use Tab/Shift-Tab to indent/unindent the selected lines * use Enter to insert a new line with the same indentation and prefix
This commit is contained in:
		| @@ -10,6 +10,7 @@ import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; | |||||||
| import {initTextExpander} from './TextExpander.js'; | import {initTextExpander} from './TextExpander.js'; | ||||||
| import {showErrorToast} from '../../modules/toast.js'; | import {showErrorToast} from '../../modules/toast.js'; | ||||||
| import {POST} from '../../modules/fetch.js'; | import {POST} from '../../modules/fetch.js'; | ||||||
|  | import {initTextareaMarkdown} from './EditorMarkdown.js'; | ||||||
|  |  | ||||||
| let elementIdCounter = 0; | let elementIdCounter = 0; | ||||||
|  |  | ||||||
| @@ -84,17 +85,6 @@ class ComboMarkdownEditor { | |||||||
|       if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button'); |       if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     this.textarea.addEventListener('keydown', (e) => { |  | ||||||
|       if (e.shiftKey) { |  | ||||||
|         e.target._shiftDown = true; |  | ||||||
|       } |  | ||||||
|     }); |  | ||||||
|     this.textarea.addEventListener('keyup', (e) => { |  | ||||||
|       if (!e.shiftKey) { |  | ||||||
|         e.target._shiftDown = false; |  | ||||||
|       } |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     const monospaceButton = this.container.querySelector('.markdown-switch-monospace'); |     const monospaceButton = this.container.querySelector('.markdown-switch-monospace'); | ||||||
|     const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true'; |     const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true'; | ||||||
|     const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text'); |     const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text'); | ||||||
| @@ -118,6 +108,7 @@ class ComboMarkdownEditor { | |||||||
|       await this.switchToEasyMDE(); |       await this.switchToEasyMDE(); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     initTextareaMarkdown(this.textarea); | ||||||
|     if (this.dropzone) { |     if (this.dropzone) { | ||||||
|       initTextareaPaste(this.textarea, this.dropzone); |       initTextareaPaste(this.textarea, this.dropzone); | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										103
									
								
								web_src/js/features/comp/EditorMarkdown.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								web_src/js/features/comp/EditorMarkdown.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | |||||||
|  | import {triggerEditorContentChanged} from './Paste.js'; | ||||||
|  |  | ||||||
|  | function handleIndentSelection(textarea, e) { | ||||||
|  |   const selStart = textarea.selectionStart; | ||||||
|  |   const selEnd = textarea.selectionEnd; | ||||||
|  |   if (selEnd === selStart) return; // do not process when no selection | ||||||
|  |  | ||||||
|  |   e.preventDefault(); | ||||||
|  |   const lines = textarea.value.split('\n'); | ||||||
|  |   const selectedLines = []; | ||||||
|  |  | ||||||
|  |   let pos = 0; | ||||||
|  |   for (let i = 0; i < lines.length; i++) { | ||||||
|  |     if (pos > selEnd) break; | ||||||
|  |     if (pos >= selStart) selectedLines.push(i); | ||||||
|  |     pos += lines[i].length + 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   for (const i of selectedLines) { | ||||||
|  |     if (e.shiftKey) { | ||||||
|  |       lines[i] = lines[i].replace(/^(\t| {1,2})/, ''); | ||||||
|  |     } else { | ||||||
|  |       lines[i] = `  ${lines[i]}`; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // re-calculating the selection range | ||||||
|  |   let newSelStart, newSelEnd; | ||||||
|  |   pos = 0; | ||||||
|  |   for (let i = 0; i < lines.length; i++) { | ||||||
|  |     if (i === selectedLines[0]) { | ||||||
|  |       newSelStart = pos; | ||||||
|  |     } | ||||||
|  |     if (i === selectedLines[selectedLines.length - 1]) { | ||||||
|  |       newSelEnd = pos + lines[i].length; | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |     pos += lines[i].length + 1; | ||||||
|  |   } | ||||||
|  |   textarea.value = lines.join('\n'); | ||||||
|  |   textarea.setSelectionRange(newSelStart, newSelEnd); | ||||||
|  |   triggerEditorContentChanged(textarea); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function handleNewline(textarea, e) { | ||||||
|  |   const selStart = textarea.selectionStart; | ||||||
|  |   const selEnd = textarea.selectionEnd; | ||||||
|  |   if (selEnd !== selStart) return; // do not process when there is a selection | ||||||
|  |  | ||||||
|  |   const value = textarea.value; | ||||||
|  |  | ||||||
|  |   // find the current line | ||||||
|  |   // * if selStart is 0, lastIndexOf(..., -1) is the same as lastIndexOf(..., 0) | ||||||
|  |   // * if lastIndexOf reruns -1, lineStart is 0 and it is still correct. | ||||||
|  |   const lineStart = value.lastIndexOf('\n', selStart - 1) + 1; | ||||||
|  |   let lineEnd = value.indexOf('\n', selStart); | ||||||
|  |   lineEnd = lineEnd < 0 ? value.length : lineEnd; | ||||||
|  |   let line = value.slice(lineStart, lineEnd); | ||||||
|  |   if (!line) return; // if the line is empty, do nothing, let the browser handle it | ||||||
|  |  | ||||||
|  |   // parse the indention | ||||||
|  |   const indention = /^\s*/.exec(line)[0]; | ||||||
|  |   line = line.slice(indention.length); | ||||||
|  |  | ||||||
|  |   // parse the prefixes: "1. ", "- ", "* ", "[ ] ", "[x] " | ||||||
|  |   // 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); | ||||||
|  |   let prefix = ''; | ||||||
|  |   if (prefixMatch) { | ||||||
|  |     prefix = prefixMatch[0]; | ||||||
|  |     if (lineStart + prefix.length > selStart) prefix = ''; // do not add new line if cursor is at prefix | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   line = line.slice(prefix.length); | ||||||
|  |   if (!indention && !prefix) return; // if no indention and no prefix, do nothing, let the browser handle it | ||||||
|  |  | ||||||
|  |   e.preventDefault(); | ||||||
|  |   if (!line) { | ||||||
|  |     // clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list | ||||||
|  |     textarea.value = value.slice(0, lineStart) + value.slice(lineEnd); | ||||||
|  |   } 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 | ||||||
|  |     const newLine = `\n${indention}${newPrefix}`; | ||||||
|  |     textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd); | ||||||
|  |     textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length); | ||||||
|  |   } | ||||||
|  |   triggerEditorContentChanged(textarea); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function initTextareaMarkdown(textarea) { | ||||||
|  |   textarea.addEventListener('keydown', (e) => { | ||||||
|  |     if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { | ||||||
|  |       // use Tab/Shift-Tab to indent/unindent the selected lines | ||||||
|  |       handleIndentSelection(textarea, e); | ||||||
|  |     } else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { | ||||||
|  |       // use Enter to insert a new line with the same indention and prefix | ||||||
|  |       handleNewline(textarea, e); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | } | ||||||
| @@ -12,7 +12,7 @@ async function uploadFile(file, uploadUrl) { | |||||||
|   return await res.json(); |   return await res.json(); | ||||||
| } | } | ||||||
|  |  | ||||||
| function triggerEditorContentChanged(target) { | export function triggerEditorContentChanged(target) { | ||||||
|   target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); |   target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -124,17 +124,19 @@ async function handleClipboardImages(editor, dropzone, images, e) { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| function handleClipboardText(textarea, text, e) { | function handleClipboardText(textarea, e, {text, isShiftDown}) { | ||||||
|   // when pasting links over selected text, turn it into [text](link), except when shift key is held |   // pasting with "shift" means "paste as original content" in most applications | ||||||
|   const {value, selectionStart, selectionEnd, _shiftDown} = textarea; |   if (isShiftDown) return; // let the browser handle it | ||||||
|   if (_shiftDown) return; |  | ||||||
|  |   // when pasting links over selected text, turn it into [text](link) | ||||||
|  |   const {value, selectionStart, selectionEnd} = textarea; | ||||||
|   const selectedText = value.substring(selectionStart, selectionEnd); |   const selectedText = value.substring(selectionStart, selectionEnd); | ||||||
|   const trimmedText = text.trim(); |   const trimmedText = text.trim(); | ||||||
|   if (selectedText && isUrl(trimmedText)) { |   if (selectedText && isUrl(trimmedText)) { | ||||||
|     e.stopPropagation(); |  | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); |     replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); | ||||||
|   } |   } | ||||||
|  |   // else, let the browser handle it | ||||||
| } | } | ||||||
|  |  | ||||||
| export function initEasyMDEPaste(easyMDE, dropzone) { | export function initEasyMDEPaste(easyMDE, dropzone) { | ||||||
| @@ -147,12 +149,19 @@ export function initEasyMDEPaste(easyMDE, dropzone) { | |||||||
| } | } | ||||||
|  |  | ||||||
| export function initTextareaPaste(textarea, dropzone) { | export function initTextareaPaste(textarea, dropzone) { | ||||||
|  |   let isShiftDown = false; | ||||||
|  |   textarea.addEventListener('keydown', (e) => { | ||||||
|  |     if (e.shiftKey) isShiftDown = true; | ||||||
|  |   }); | ||||||
|  |   textarea.addEventListener('keyup', (e) => { | ||||||
|  |     if (!e.shiftKey) isShiftDown = false; | ||||||
|  |   }); | ||||||
|   textarea.addEventListener('paste', (e) => { |   textarea.addEventListener('paste', (e) => { | ||||||
|     const {images, text} = getPastedContent(e); |     const {images, text} = getPastedContent(e); | ||||||
|     if (images.length) { |     if (images.length) { | ||||||
|       handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); |       handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); | ||||||
|     } else if (text) { |     } else if (text) { | ||||||
|       handleClipboardText(textarea, text, e); |       handleClipboardText(textarea, e, {text, isShiftDown}); | ||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 wxiaoguang
					wxiaoguang