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" -)}} +
+ {{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" + )}} +
{{if .IsAttachmentEnabled}}
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); + } + }; +}