From 621e1ff9c9ec04ea8e6d68cd8e38bb5734f29bdc Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
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});
     }
   });
 }