From 621e1ff9c9ec04ea8e6d68cd8e38bb5734f29bdc Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 21 Jun 2024 16:14:40 +0800 Subject: [PATCH] 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 --- .../js/features/comp/ComboMarkdownEditor.js | 13 +-- web_src/js/features/comp/EditorMarkdown.js | 103 ++++++++++++++++++ web_src/js/features/comp/Paste.js | 23 ++-- 3 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 web_src/js/features/comp/EditorMarkdown.js diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index d3fab375a9..7186989ffa 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -10,6 +10,7 @@ import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; import {initTextExpander} from './TextExpander.js'; import {showErrorToast} from '../../modules/toast.js'; import {POST} from '../../modules/fetch.js'; +import {initTextareaMarkdown} from './EditorMarkdown.js'; let elementIdCounter = 0; @@ -84,17 +85,6 @@ class ComboMarkdownEditor { 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 monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true'; const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text'); @@ -118,6 +108,7 @@ class ComboMarkdownEditor { await this.switchToEasyMDE(); }); + initTextareaMarkdown(this.textarea); if (this.dropzone) { initTextareaPaste(this.textarea, this.dropzone); } diff --git a/web_src/js/features/comp/EditorMarkdown.js b/web_src/js/features/comp/EditorMarkdown.js new file mode 100644 index 0000000000..cf412e3807 --- /dev/null +++ b/web_src/js/features/comp/EditorMarkdown.js @@ -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); + } + }); +} diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/Paste.js index 35a7ceaef8..c72434c4cc 100644 --- a/web_src/js/features/comp/Paste.js +++ b/web_src/js/features/comp/Paste.js @@ -12,7 +12,7 @@ async function uploadFile(file, uploadUrl) { return await res.json(); } -function triggerEditorContentChanged(target) { +export function triggerEditorContentChanged(target) { 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) { - // when pasting links over selected text, turn it into [text](link), except when shift key is held - const {value, selectionStart, selectionEnd, _shiftDown} = textarea; - if (_shiftDown) return; +function handleClipboardText(textarea, e, {text, isShiftDown}) { + // pasting with "shift" means "paste as original content" in most applications + if (isShiftDown) return; // let the browser handle it + + // when pasting links over selected text, turn it into [text](link) + const {value, selectionStart, selectionEnd} = textarea; const selectedText = value.substring(selectionStart, selectionEnd); const trimmedText = text.trim(); if (selectedText && isUrl(trimmedText)) { - e.stopPropagation(); e.preventDefault(); replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); } + // else, let the browser handle it } export function initEasyMDEPaste(easyMDE, dropzone) { @@ -147,12 +149,19 @@ export function initEasyMDEPaste(easyMDE, 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) => { const {images, text} = getPastedContent(e); if (images.length) { handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); } else if (text) { - handleClipboardText(textarea, text, e); + handleClipboardText(textarea, e, {text, isShiftDown}); } }); }