From 93eb914438fcec234842ed626278fecab3fefba6 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Sat, 8 Apr 2023 01:03:29 +0800
Subject: [PATCH] Improve markdown editor: width, height, preferred (#23895)

Follow #23876


1. Fine tune the heights of the editors (like before)
    * Auto expand the editor (increase/decrease the height) when editing
2. Remember user's last used editor (textarea/easymde) in LocalStorage,
then next time the editor will be switched automatically
* No need to introduce extra config option, it satisfies all users,
including who prefer EasyMDE
3. Also fix the width problem of Review Panel

Screenshot:

<details>


![image](https://user-images.githubusercontent.com/2114189/229518585-2e05827e-8355-48f3-a20c-2c8b9e60ce74.png)


![image](https://user-images.githubusercontent.com/2114189/229518173-4caa6da7-6ad9-40e9-bf1a-ceddfcd4b37f.png)


![image](https://user-images.githubusercontent.com/2114189/229507886-148e9b84-9b58-46d1-ba3f-727e1396f476.png)


![image](https://user-images.githubusercontent.com/2114189/229518258-9f522294-1e64-4b06-91ab-ab43b0353aaa.png)


![image](https://user-images.githubusercontent.com/2114189/229507752-6d540ac7-7748-4bb6-bc09-28acab32d31b.png)


![image](https://user-images.githubusercontent.com/2114189/229510899-de322af5-57e8-4dc5-9a61-771a3b1bee79.png)


</details>

---------

Co-authored-by: silverwind <me@silverwind.io>
---
 templates/repo/issue/comment_tab.tmpl         |  19 +--
 web_src/css/editor-markdown.css               |  11 +-
 web_src/css/repository.css                    |  21 ---
 web_src/css/review.css                        |  17 +--
 .../js/features/comp/ComboMarkdownEditor.js   |  64 +++++++--
 web_src/js/features/repo-wiki.js              |   5 +
 web_src/js/utils/dom.js                       | 121 ++++++++++++++++++
 7 files changed, 203 insertions(+), 55 deletions(-)

diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl
index 2212d99a10..c40e6ddf32 100644
--- a/templates/repo/issue/comment_tab.tmpl
+++ b/templates/repo/issue/comment_tab.tmpl
@@ -3,14 +3,17 @@
 {{if not $textareaContent}}{{$textareaContent = .PullRequestTemplate}}{{end}}
 {{if not $textareaContent}}{{$textareaContent = .content}}{{end}}
 
-{{template "shared/combomarkdowneditor" (dict
-	"locale" $.locale
-	"MarkdownPreviewUrl" (print .Repository.Link "/markup")
-	"MarkdownPreviewContext" .RepoLink
-	"TextareaName" "content"
-	"TextareaContent" $textareaContent
-	"DropzoneParentContainer" "form, .ui.form"
-)}}
+<div class="field">
+	{{template "shared/combomarkdowneditor" (dict
+		"locale" $.locale
+		"MarkdownPreviewUrl" (print .Repository.Link "/markup")
+		"MarkdownPreviewContext" .RepoLink
+		"TextareaName" "content"
+		"TextareaContent" $textareaContent
+		"TextareaPlaceholder"  ($.locale.Tr "repo.diff.comment.placeholder")
+		"DropzoneParentContainer" "form, .ui.form"
+	)}}
+</div>
 
 {{if .IsAttachmentEnabled}}
 	<div class="field">
diff --git a/web_src/css/editor-markdown.css b/web_src/css/editor-markdown.css
index 31ffeb06d0..da64164aec 100644
--- a/web_src/css/editor-markdown.css
+++ b/web_src/css/editor-markdown.css
@@ -18,8 +18,15 @@
   cursor: pointer;
 }
 
-.combo-markdown-editor .markdown-text-editor {
+.ui.form .combo-markdown-editor textarea.markdown-text-editor,
+.combo-markdown-editor textarea.markdown-text-editor {
   display: block;
   width: 100%;
-  height: 200px;
+  min-height: 200px;
+  max-height: calc(100vh - 200px);
+  resize: vertical;
+}
+
+.combo-markdown-editor .CodeMirror-scroll {
+  max-height: calc(100vh - 200px);
 }
diff --git a/web_src/css/repository.css b/web_src/css/repository.css
index b4bfd17352..de1c2eda58 100644
--- a/web_src/css/repository.css
+++ b/web_src/css/repository.css
@@ -544,10 +544,6 @@
   margin: 0;
 }
 
-.repository .comment textarea {
-  max-height: none !important;
-}
-
 .repository.new.issue .comment.form .comment .avatar {
   width: 3em;
 }
@@ -1068,11 +1064,6 @@
   min-height: 5rem;
 }
 
-.repository.view.issue .comment-list .comment .ui.form textarea {
-  height: 200px;
-  font-family: var(--fonts-monospace);
-}
-
 .repository.view.issue .comment-list .comment .edit.buttons {
   margin-top: 10px;
 }
@@ -1191,15 +1182,6 @@
   margin-top: -8px;
 }
 
-.repository .comment.form .content textarea {
-  height: 200px;
-  font-family: var(--fonts-monospace);
-}
-
-.repository .comment.form .content .CodeMirror-scroll {
-  max-height: 85vh;
-}
-
 .repository .milestone.list {
   list-style: none;
   padding-top: 15px;
@@ -2123,9 +2105,6 @@
   margin-top: 0;
 }
 
-.repository.wiki .form .CodeMirror-scroll {
-  max-height: 85vh;
-}
 
 @media (max-width: 767px) {
   .repository.wiki .dividing.header .stackable.grid .button {
diff --git a/web_src/css/review.css b/web_src/css/review.css
index c00a536491..0111311d3c 100644
--- a/web_src/css/review.css
+++ b/web_src/css/review.css
@@ -154,8 +154,11 @@
   margin: 0.5em;
 }
 
+.comment-code-cloud .editor-statusbar {
+  display: none;
+}
+
 .comment-code-cloud .footer {
-  border-top: 1px solid var(--color-secondary);
   padding: 10px 0;
 }
 
@@ -218,15 +221,9 @@ a.blob-excerpt:hover {
   max-height: calc(100vh - 360px);
 }
 
-.review-box-panel .editor-toolbar,
-.review-box-panel .CodeMirror-scroll {
-  width: min(calc(100vw - 2em), 800px);
-  max-width: none;
-}
-
-.review-box-panel .combo-markdown-editor textarea {
-  width: 730px;
-  max-width: calc(100vw - 70px);
+.review-box-panel .combo-markdown-editor {
+  width: 730px; /* this width matches current EasyMDE's toolbar's width */
+  max-width: calc(100vw - 70px); /* leave enough space on left, and align the page content */
 }
 
 #review-box {
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
index 4905ec2341..c1607a1da8 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.js
+++ b/web_src/js/features/comp/ComboMarkdownEditor.js
@@ -1,8 +1,8 @@
 import '@github/markdown-toolbar-element';
-import {attachTribute} from '../tribute.js';
-import {hideElem, showElem} from '../../utils/dom.js';
-import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
 import $ from 'jquery';
+import {attachTribute} from '../tribute.js';
+import {hideElem, showElem, autosize} from '../../utils/dom.js';
+import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
 import {initMarkupContent} from '../../markup/content.js';
 import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
 import {attachRefIssueContextPopup} from '../contextpopup.js';
@@ -39,31 +39,55 @@ class ComboMarkdownEditor {
   }
 
   async init() {
+    this.prepareEasyMDEToolbarActions();
+
+    this.setupTab();
+    this.setupDropzone();
+
+    this.setupTextarea();
+
+    await attachTribute(this.textarea, {mentions: true, emoji: true});
+
+    if (this.userPreferredEditor === 'easymde') {
+      await this.switchToEasyMDE();
+    }
+  }
+
+  applyEditorHeights(el, heights) {
+    if (!heights) return;
+    if (heights.minHeight) el.style.minHeight = heights.minHeight;
+    if (heights.height) el.style.height = heights.height;
+    if (heights.maxHeight) el.style.maxHeight = heights.maxHeight;
+  }
+
+  setupTextarea() {
     this.textarea = this.container.querySelector('.markdown-text-editor');
     this.textarea._giteaComboMarkdownEditor = this;
-    this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter)}`;
-    this.textarea.addEventListener('input', (e) => {this.options?.onContentChanged?.(this, e)});
+    this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter++)}`;
+    this.textarea.addEventListener('input', (e) => this.options?.onContentChanged?.(this, e));
+    this.applyEditorHeights(this.textarea, this.options.editorHeights);
+    this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130});
+
     this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar');
     this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id);
 
-    elementIdCounter++;
-
     this.switchToEasyMDEButton = this.container.querySelector('.markdown-switch-easymde');
     this.switchToEasyMDEButton?.addEventListener('click', async (e) => {
       e.preventDefault();
+      this.userPreferredEditor = 'easymde';
       await this.switchToEasyMDE();
     });
 
-    await attachTribute(this.textarea, {mentions: true, emoji: true});
+    if (this.dropzone) {
+      initTextareaImagePaste(this.textarea, this.dropzone);
+    }
+  }
 
+  setupDropzone() {
     const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
     if (dropzoneParentContainer) {
       this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone');
-      initTextareaImagePaste(this.textarea, this.dropzone);
     }
-
-    this.setupTab();
-    this.prepareEasyMDEToolbarActions();
   }
 
   setupTab() {
@@ -134,7 +158,10 @@ class ComboMarkdownEditor {
         title: 'Add Checkbox (checked)',
       },
       'gitea-switch-to-textarea': {
-        action: this.switchToTextarea.bind(this),
+        action: () => {
+          this.userPreferredEditor = 'textarea';
+          this.switchToTextarea();
+        },
         className: 'fa fa-file',
         title: 'Revert to simple textarea',
       },
@@ -169,7 +196,7 @@ class ComboMarkdownEditor {
     return processed;
   }
 
-  async switchToTextarea() {
+  switchToTextarea() {
     showElem(this.textareaMarkdownToolbar);
     if (this.easyMDE) {
       this.easyMDE.toTextArea();
@@ -218,6 +245,7 @@ class ComboMarkdownEditor {
         }
       },
     });
+    this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
     await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
     initEasyMDEImagePaste(this.easyMDE, this.dropzone);
     hideElem(this.textareaMarkdownToolbar);
@@ -236,6 +264,7 @@ class ComboMarkdownEditor {
     } else {
       this.textarea.value = v;
     }
+    this.textareaAutosize.resizeToFit();
   }
 
   focus() {
@@ -254,6 +283,13 @@ class ComboMarkdownEditor {
       this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0);
     }
   }
+
+  get userPreferredEditor() {
+    return window.localStorage.getItem(`markdown-editor-${this.options.useScene ?? 'default'}`);
+  }
+  set userPreferredEditor(s) {
+    window.localStorage.setItem(`markdown-editor-${this.options.useScene ?? 'default'}`, s);
+  }
 }
 
 export function getComboMarkdownEditor(el) {
diff --git a/web_src/js/features/repo-wiki.js b/web_src/js/features/repo-wiki.js
index a48f63dcb1..09202a303c 100644
--- a/web_src/js/features/repo-wiki.js
+++ b/web_src/js/features/repo-wiki.js
@@ -44,6 +44,11 @@ async function initRepoWikiFormEditor() {
   renderEasyMDEPreview();
 
   editor = await initComboMarkdownEditor($editorContainer, {
+    useScene: 'wiki',
+    // EasyMDE has some problems of height definition, it has inline style height 300px by default, so we also use inline styles to override it.
+    // And another benefit is that we only need to write the style once for both editors.
+    // TODO: Move height style to CSS after EasyMDE removal.
+    editorHeights: {minHeight: '300px', height: 'calc(100vh - 600px)'},
     previewMode: 'gfm',
     previewWiki: true,
     easyMDEOptions: {
diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js
index c160d37f6c..6a9ee56eeb 100644
--- a/web_src/js/utils/dom.js
+++ b/web_src/js/utils/dom.js
@@ -49,3 +49,124 @@ export function onDomReady(cb) {
     cb();
   }
 }
+
+// autosize a textarea to fit content. Based on
+// https://github.com/github/textarea-autosize
+// ---------------------------------------------------------------------
+// Copyright (c) 2018 GitHub, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+// ---------------------------------------------------------------------
+export function autosize(textarea, {viewportMarginBottom = 0} = {}) {
+  let isUserResized = false;
+  // lastStyleHeight and initialStyleHeight are CSS values like '100px'
+  let lastMouseX, lastMouseY, lastStyleHeight, initialStyleHeight;
+
+  function onUserResize(event) {
+    if (isUserResized) return;
+    if (lastMouseX !== event.clientX || lastMouseY !== event.clientY) {
+      const newStyleHeight = textarea.style.height;
+      if (lastStyleHeight && lastStyleHeight !== newStyleHeight) {
+        isUserResized = true;
+      }
+      lastStyleHeight = newStyleHeight;
+    }
+
+    lastMouseX = event.clientX;
+    lastMouseY = event.clientY;
+  }
+
+  function overflowOffset() {
+    let offsetTop = 0;
+    let el = textarea;
+
+    while (el !== document.body && el !== null) {
+      offsetTop += el.offsetTop || 0;
+      el = el.offsetParent;
+    }
+
+    const top = offsetTop - document.defaultView.scrollY;
+    const bottom = document.documentElement.clientHeight - (top + textarea.offsetHeight);
+    return {top, bottom};
+  }
+
+  function resizeToFit() {
+    if (isUserResized) return;
+    if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return;
+
+    try {
+      const {top, bottom} = overflowOffset();
+      const isOutOfViewport = top < 0 || bottom < 0;
+
+      const computedStyle = getComputedStyle(textarea);
+      const topBorderWidth = parseFloat(computedStyle.borderTopWidth);
+      const bottomBorderWidth = parseFloat(computedStyle.borderBottomWidth);
+      const isBorderBox = computedStyle.boxSizing === 'border-box';
+      const borderAddOn = isBorderBox ? topBorderWidth + bottomBorderWidth : 0;
+
+      const adjustedViewportMarginBottom = bottom < viewportMarginBottom ? bottom : viewportMarginBottom;
+      const curHeight = parseFloat(computedStyle.height);
+      const maxHeight = curHeight + bottom - adjustedViewportMarginBottom;
+
+      textarea.style.height = 'auto';
+      let newHeight = textarea.scrollHeight + borderAddOn;
+
+      if (isOutOfViewport) {
+        // it is already out of the viewport:
+        // * if the textarea is expanding: do not resize it
+        if (newHeight > curHeight) {
+          newHeight = curHeight;
+        }
+        // * if the textarea is shrinking, shrink line by line (just use the
+        //   scrollHeight). do not apply max-height limit, otherwise the page
+        //   flickers and the textarea jumps
+      } else {
+        // * if it is in the viewport, apply the max-height limit
+        newHeight = Math.min(maxHeight, newHeight);
+      }
+
+      textarea.style.height = `${newHeight}px`;
+      lastStyleHeight = textarea.style.height;
+    } finally {
+      // ensure that the textarea is fully scrolled to the end, when the cursor
+      // is at the end during an input event
+      if (textarea.selectionStart === textarea.selectionEnd &&
+          textarea.selectionStart === textarea.value.length) {
+        textarea.scrollTop = textarea.scrollHeight;
+      }
+    }
+  }
+
+  function onFormReset() {
+    isUserResized = false;
+    if (initialStyleHeight !== undefined) {
+      textarea.style.height = initialStyleHeight;
+    } else {
+      textarea.style.removeProperty('height');
+    }
+  }
+
+  textarea.addEventListener('mousemove', onUserResize);
+  textarea.addEventListener('input', resizeToFit);
+  textarea.form?.addEventListener('reset', onFormReset);
+  initialStyleHeight = textarea.style.height ?? undefined;
+  if (textarea.value) resizeToFit();
+
+  return {
+    resizeToFit,
+    destroy() {
+      textarea.removeEventListener('mousemove', onUserResize);
+      textarea.removeEventListener('input', resizeToFit);
+      textarea.form?.removeEventListener('reset', onFormReset);
+    }
+  };
+}